diff --git a/imgui.ini b/imgui.ini
index ef2059b..5af8267 100644
--- a/imgui.ini
+++ b/imgui.ini
@@ -9,7 +9,7 @@ Size=432,649
Collapsed=0
[Window][Simulator]
-Pos=499,177
-Size=1200,681
+Pos=564,96
+Size=893,571
Collapsed=0
diff --git a/src/Nexus.Core/BotConfig.cs b/src/Nexus.Core/BotConfig.cs
index eac93b7..bc304c0 100644
--- a/src/Nexus.Core/BotConfig.cs
+++ b/src/Nexus.Core/BotConfig.cs
@@ -14,6 +14,9 @@ public class BotConfig
// Navigation
public float WorldToGrid { get; set; } = 23f / 250f;
+ // Combat engagement — suppress navigation when enemies are within this range
+ public float CombatEngagementRange { get; set; } = 600f;
+
// Loot
public float LootPickupRange { get; set; } = 600f;
diff --git a/src/Nexus.Core/MovementBlender.cs b/src/Nexus.Core/MovementBlender.cs
index 8fa46ed..87bce56 100644
--- a/src/Nexus.Core/MovementBlender.cs
+++ b/src/Nexus.Core/MovementBlender.cs
@@ -21,8 +21,10 @@ public sealed class MovementBlender
// Stuck detection
private Vector2 _lastResolvePos;
private int _stuckFrames;
- private const int StuckFrameThreshold = 30; // ~0.5s at 60Hz
- private const float StuckMovePerFrame = 3f; // must move > 3 world units per frame to count as moving
+ private const int StuckFrameThreshold = 15; // ~250ms at 60Hz
+ private const int StuckRecoveryThreshold = 45; // ~750ms — try random direction to break free
+ private const float StuckMovePerFrame = 3f; // must move > 3 world units per frame to count as moving
+ private static readonly Random StuckRng = new();
// EMA smoothing to dampen terrain validation jitter.
// Snap decision based on INTENT change (pre-terrain), not terrain output — prevents
@@ -58,15 +60,11 @@ public sealed class MovementBlender
public void Clear() => _intents.Clear();
///
- /// Blends all submitted intents and validates against terrain.
- /// Applies EMA smoothing after terrain validation to dampen probe jitter.
+ /// Updates stuck detection based on player movement. Call BEFORE systems run
+ /// so that IsStuck is available for systems to check (e.g. MovementSystem suppresses orbit).
///
- public void Resolve(WalkabilitySnapshot? terrain, Vector2 playerPos, float worldToGrid)
+ public void UpdateStuckState(Vector2 playerPos)
{
- IsUrgentFlee = false;
-
- // ── Stuck detection ──
- // If player barely moves for ~0.5s, suppress orbit/herd so navigation can guide out
var moved = Vector2.Distance(playerPos, _lastResolvePos);
if (moved < StuckMovePerFrame)
_stuckFrames++;
@@ -74,11 +72,31 @@ public sealed class MovementBlender
_stuckFrames = Math.Max(0, _stuckFrames - 3); // recover 3x faster than building up
_lastResolvePos = playerPos;
IsStuck = _stuckFrames > StuckFrameThreshold;
+ }
+
+ ///
+ /// Blends all submitted intents and validates against terrain.
+ /// Applies EMA smoothing after terrain validation to dampen probe jitter.
+ ///
+ public void Resolve(WalkabilitySnapshot? terrain, Vector2 playerPos, float worldToGrid)
+ {
+ IsUrgentFlee = false;
if (IsStuck)
{
- // Keep only flee (L0, L1) and navigation (L3) — drop orbit (L2) and herd (L4)
+ // Keep only flee (L0, L1), navigation (L3), and wall push (L5) — drop orbit (L2) and herd (L4)
_intents.RemoveAll(i => i.Layer == 2 || i.Layer == 4);
+
+ // After 1s stuck, inject a random perpendicular nudge to break free
+ if (_stuckFrames > StuckRecoveryThreshold)
+ {
+ var angle = StuckRng.NextDouble() * Math.PI * 2;
+ var nudge = new System.Numerics.Vector2((float)Math.Cos(angle), (float)Math.Sin(angle));
+ _intents.Add(new MovementIntent(1, nudge, 0.7f, "StuckEscape"));
+ // Reset counter so we try a new direction periodically
+ if (_stuckFrames % 30 == 0)
+ _stuckFrames = StuckRecoveryThreshold + 1;
+ }
}
_lastIntents = new List(_intents);
diff --git a/src/Nexus.Core/TerrainQuery.cs b/src/Nexus.Core/TerrainQuery.cs
index e6237fc..06e9809 100644
--- a/src/Nexus.Core/TerrainQuery.cs
+++ b/src/Nexus.Core/TerrainQuery.cs
@@ -88,8 +88,8 @@ public static class TerrainQuery
///
public static Vector2 ComputeWallRepulsion(WalkabilitySnapshot terrain, Vector2 playerPos, float worldToGrid)
{
- const float probeNear = 25f; // ~2-3 grid cells
- const float probeFar = 60f; // ~5-6 grid cells
+ const float probeNear = 40f; // ~3-4 grid cells
+ const float probeFar = 100f; // ~9-10 grid cells
var push = Vector2.Zero;
@@ -122,6 +122,62 @@ public static class TerrainQuery
return Vector2.Normalize(push);
}
+ ///
+ /// Predictive wall steering — casts rays ahead along the movement direction.
+ /// If forward is blocked but a side is clear, returns a lateral steering vector.
+ ///
+ public static Vector2 ComputeWallSteering(
+ WalkabilitySnapshot terrain, Vector2 playerPos, Vector2 moveDir, float worldToGrid)
+ {
+ if (moveDir.LengthSquared() < 0.0001f)
+ return Vector2.Zero;
+
+ var dir = Vector2.Normalize(moveDir);
+ var leftDir = Rotate(dir, 30f);
+ var rightDir = Rotate(dir, -30f);
+
+ ReadOnlySpan distances = [40f, 80f, 120f];
+
+ var forwardBlocked = false;
+ var leftClear = true;
+ var rightClear = true;
+
+ foreach (var dist in distances)
+ {
+ var fwd = playerPos + dir * dist;
+ var fx = (int)(fwd.X * worldToGrid);
+ var fy = (int)(fwd.Y * worldToGrid);
+ if (!terrain.IsWalkable(fx, fy))
+ forwardBlocked = true;
+
+ var left = playerPos + leftDir * dist;
+ var lx = (int)(left.X * worldToGrid);
+ var ly = (int)(left.Y * worldToGrid);
+ if (!terrain.IsWalkable(lx, ly))
+ leftClear = false;
+
+ var right = playerPos + rightDir * dist;
+ var rx = (int)(right.X * worldToGrid);
+ var ry = (int)(right.Y * worldToGrid);
+ if (!terrain.IsWalkable(rx, ry))
+ rightClear = false;
+ }
+
+ if (!forwardBlocked)
+ return Vector2.Zero;
+
+ // Steer toward the clear side
+ var lateral = new Vector2(-dir.Y, dir.X); // perpendicular (left)
+ if (leftClear && !rightClear)
+ return lateral;
+ if (rightClear && !leftClear)
+ return -lateral;
+ if (leftClear && rightClear)
+ return lateral; // default left when both clear
+ // Both blocked — push backward
+ return -dir;
+ }
+
private static Vector2 Rotate(Vector2 v, float degrees)
{
float rad = degrees * MathF.PI / 180f;
diff --git a/src/Nexus.Pathfinding/NavigationController.cs b/src/Nexus.Pathfinding/NavigationController.cs
index b24f646..ea929d3 100644
--- a/src/Nexus.Pathfinding/NavigationController.cs
+++ b/src/Nexus.Pathfinding/NavigationController.cs
@@ -218,6 +218,8 @@ public sealed class NavigationController
return;
}
Log.Debug("NavigationController: stuck detected, repathing");
+ _positionHistory.Clear();
+ _stuckGraceTicks = 120; // 2 seconds grace before next stuck check
}
}
diff --git a/src/Nexus.Pathfinding/PathFinder.cs b/src/Nexus.Pathfinding/PathFinder.cs
index c29fdf5..5c3ba60 100644
--- a/src/Nexus.Pathfinding/PathFinder.cs
+++ b/src/Nexus.Pathfinding/PathFinder.cs
@@ -105,6 +105,15 @@ public static class PathFinder
stepCost *= 1.5f;
}
+ // Wall-proximity penalty — prefer corridor centers
+ var wallCount = 0;
+ for (var d = 0; d < 8; d++)
+ {
+ if (!terrain.IsWalkable(nx + Dx[d], ny + Dy[d]))
+ wallCount++;
+ }
+ stepCost += wallCount * 0.5f;
+
var tentativeG = currentG + stepCost;
if (tentativeG < gScore.GetValueOrDefault(neighbor, float.MaxValue))
diff --git a/src/Nexus.Simulator/Config/SimConfig.cs b/src/Nexus.Simulator/Config/SimConfig.cs
index 567f085..4724343 100644
--- a/src/Nexus.Simulator/Config/SimConfig.cs
+++ b/src/Nexus.Simulator/Config/SimConfig.cs
@@ -3,10 +3,18 @@ namespace Nexus.Simulator.Config;
public class SimConfig
{
// Terrain
- public int TerrainWidth { get; set; } = 500;
- public int TerrainHeight { get; set; } = 500;
+ public int TerrainWidth { get; set; } = 1500;
+ public int TerrainHeight { get; set; } = 1500;
public float WorldToGrid { get; set; } = 23f / 250f;
+ // Dungeon generation
+ public int DungeonRoomCountMin { get; set; } = 10;
+ public int DungeonRoomCountMax { get; set; } = 18;
+ public int DungeonRoomSizeMin { get; set; } = 55; // grid cells (~600 world units)
+ public int DungeonRoomSizeMax { get; set; } = 110; // grid cells (~1200 world units)
+ public int DungeonCorridorWidth { get; set; } = 13; // grid cells (~140 world units)
+ public float DungeonEndReachDist { get; set; } = 150f; // world units
+
// Player
public float PlayerMoveSpeed { get; set; } = 400f;
public int PlayerMaxHealth { get; set; } = 800;
@@ -55,10 +63,6 @@ public class SimConfig
public float ProjectileHitRadius { get; set; } = 80f;
public int SkillBaseDamage { get; set; } = 200;
- // Terrain expansion
- public int ExpandThreshold { get; set; } = 50;
- public int ExpandAmount { get; set; } = 250;
-
// Simulation
public float SpeedMultiplier { get; set; } = 1f;
public bool IsPaused { get; set; }
diff --git a/src/Nexus.Simulator/Program.cs b/src/Nexus.Simulator/Program.cs
index 891338d..76b8a01 100644
--- a/src/Nexus.Simulator/Program.cs
+++ b/src/Nexus.Simulator/Program.cs
@@ -72,8 +72,8 @@ foreach (var sys in systems)
// ── Start simulation poller ──
poller.Start();
-// ── Start exploring ──
-nav.Explore();
+// ── Navigate to dungeon end ──
+nav.NavigateTo(world.EndWorldPos);
// ── Bot logic thread ──
var actionQueue = new ActionQueue();
@@ -98,6 +98,13 @@ var botThread = new Thread(() =>
var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
ActionExecutor.Execute(resolved, input, moveTracker, movementBlender, state.Player.Position);
+ // Check if dungeon end reached — regenerate and re-navigate
+ if (world.ReachedEnd)
+ {
+ world.RegenerateTerrain();
+ nav.NavigateTo(world.EndWorldPos);
+ }
+
botTickCount++;
if (maxTicks.HasValue && botTickCount >= maxTicks.Value)
{
diff --git a/src/Nexus.Simulator/Rendering/SimRenderer.cs b/src/Nexus.Simulator/Rendering/SimRenderer.cs
index 1b1a344..0338ea6 100644
--- a/src/Nexus.Simulator/Rendering/SimRenderer.cs
+++ b/src/Nexus.Simulator/Rendering/SimRenderer.cs
@@ -65,7 +65,8 @@ public class SimRenderer
// 1. Terrain
TerrainRenderer.Draw(drawList, _world.Terrain, vt, canvasSize,
_nav.ExploredGrid, _nav.ExploredWidth, _nav.ExploredHeight,
- _nav.ExploredOffsetX, _nav.ExploredOffsetY);
+ _nav.ExploredOffsetX, _nav.ExploredOffsetY,
+ _world.StartWorldPos, _world.EndWorldPos);
// 2. Path
PathRenderer.Draw(drawList, _nav, vt);
@@ -93,7 +94,10 @@ public class SimRenderer
var minimapSize = 150f;
var minimapOrigin = canvasOrigin + canvasSize - new Vector2(minimapSize + 10, minimapSize + 10);
var playerGridPos = _world.Player.Position * _config.WorldToGrid;
- TerrainRenderer.DrawMinimap(drawList, _world.Terrain, playerGridPos, minimapOrigin, minimapSize);
+ var startGridPos = _world.StartWorldPos * _config.WorldToGrid;
+ var endGridPos = _world.EndWorldPos * _config.WorldToGrid;
+ TerrainRenderer.DrawMinimap(drawList, _world.Terrain, playerGridPos, minimapOrigin, minimapSize,
+ startGridPos, endGridPos);
// HUD text
DrawHud(drawList, canvasOrigin, state);
diff --git a/src/Nexus.Simulator/Rendering/TerrainRenderer.cs b/src/Nexus.Simulator/Rendering/TerrainRenderer.cs
index b68d76c..edf1ea1 100644
--- a/src/Nexus.Simulator/Rendering/TerrainRenderer.cs
+++ b/src/Nexus.Simulator/Rendering/TerrainRenderer.cs
@@ -13,7 +13,8 @@ public static class TerrainRenderer
public static void Draw(ImDrawListPtr drawList, WalkabilitySnapshot terrain,
ViewTransform vt, Vector2 canvasSize,
bool[]? exploredGrid = null, int exploredWidth = 0, int exploredHeight = 0,
- int exploredOffsetX = 0, int exploredOffsetY = 0)
+ int exploredOffsetX = 0, int exploredOffsetY = 0,
+ Vector2? startWorldPos = null, Vector2? endWorldPos = null)
{
var cellSize = vt.Zoom;
if (cellSize < 0.5f) return;
@@ -71,13 +72,32 @@ public static class TerrainRenderer
var p3 = vt.GridToScreen(gx, gy + step); // left
drawList.AddQuadFilled(p0, p1, p2, p3, color);
}
+
+ // Draw start marker (green circle)
+ if (startWorldPos.HasValue)
+ {
+ var sp = vt.WorldToScreen(startWorldPos.Value);
+ var r = Math.Max(6f, vt.Zoom * 3f);
+ drawList.AddCircleFilled(sp, r, 0xFF00FF00); // green
+ drawList.AddCircle(sp, r, 0xFF00AA00, 0, 2f);
+ }
+
+ // Draw end marker (red circle)
+ if (endWorldPos.HasValue)
+ {
+ var ep = vt.WorldToScreen(endWorldPos.Value);
+ var r = Math.Max(6f, vt.Zoom * 3f);
+ drawList.AddCircleFilled(ep, r, 0xFF0000FF); // red (ABGR)
+ drawList.AddCircle(ep, r, 0xFF0000AA, 0, 2f);
+ }
}
///
/// Draws a minimap in the corner (top-down, no rotation).
///
public static void DrawMinimap(ImDrawListPtr drawList, WalkabilitySnapshot terrain,
- Vector2 playerGridPos, Vector2 minimapOrigin, float minimapSize)
+ Vector2 playerGridPos, Vector2 minimapOrigin, float minimapSize,
+ Vector2? startGridPos = null, Vector2? endGridPos = null)
{
var scaleX = minimapSize / terrain.Width;
var scaleY = minimapSize / terrain.Height;
@@ -102,10 +122,26 @@ public static class TerrainRenderer
0xFF2A2A3F);
}
- // Player dot — convert absolute grid pos to local
+ // Start marker (green dot)
+ if (startGridPos.HasValue)
+ {
+ var startLocal = startGridPos.Value - new Vector2(terrain.OffsetX, terrain.OffsetY);
+ var startPx = minimapOrigin + startLocal * scale;
+ drawList.AddCircleFilled(startPx, 3f, 0xFF00FF00); // green
+ }
+
+ // End marker (red dot)
+ if (endGridPos.HasValue)
+ {
+ var endLocal = endGridPos.Value - new Vector2(terrain.OffsetX, terrain.OffsetY);
+ var endPx = minimapOrigin + endLocal * scale;
+ drawList.AddCircleFilled(endPx, 3f, 0xFF0000FF); // red
+ }
+
+ // Player dot (cyan) — convert absolute grid pos to local
var playerLocalPos = playerGridPos - new Vector2(terrain.OffsetX, terrain.OffsetY);
var playerPx = minimapOrigin + playerLocalPos * scale;
- drawList.AddCircleFilled(playerPx, 3f, 0xFF00FF00);
+ drawList.AddCircleFilled(playerPx, 3f, 0xFFFFFF00); // cyan (ABGR)
// Border
drawList.AddRect(minimapOrigin,
diff --git a/src/Nexus.Simulator/World/SimWorld.cs b/src/Nexus.Simulator/World/SimWorld.cs
index 1c9f858..681d295 100644
--- a/src/Nexus.Simulator/World/SimWorld.cs
+++ b/src/Nexus.Simulator/World/SimWorld.cs
@@ -16,6 +16,11 @@ public class SimWorld
public List ActiveEffects { get; } = [];
public WalkabilitySnapshot Terrain { get; private set; }
public long TickNumber { get; private set; }
+ public Vector2 StartWorldPos { get; private set; }
+ public Vector2 EndWorldPos { get; private set; }
+ public bool ReachedEnd { get; private set; }
+
+ private int _dungeonSeed;
// Pending respawns
private readonly List<(float timer, MonsterRarity rarity)> _respawnQueue = [];
@@ -28,29 +33,45 @@ public class SimWorld
public SimWorld(SimConfig config)
{
_config = config;
- Terrain = TerrainGenerator.Generate(config.TerrainWidth, config.TerrainHeight);
+ _dungeonSeed = _rng.Next();
+
+ var dungeon = TerrainGenerator.GenerateDungeon(
+ config.TerrainWidth, config.TerrainHeight, _dungeonSeed,
+ config.DungeonRoomCountMin, config.DungeonRoomCountMax,
+ config.DungeonRoomSizeMin, config.DungeonRoomSizeMax,
+ config.DungeonCorridorWidth, config.WorldToGrid);
+
+ Terrain = dungeon.Terrain;
+ StartWorldPos = dungeon.StartWorldPos;
+ EndWorldPos = dungeon.EndWorldPos;
- // Spawn player at center
- var gridToWorld = 1f / config.WorldToGrid;
- var (sx, sy) = TerrainGenerator.FindSpawnPosition(Terrain);
Player = new SimPlayer(
config.PlayerMaxHealth, config.PlayerMaxMana, config.PlayerMaxEs,
config.PlayerMoveSpeed, config.PlayerHealthRegen, config.PlayerManaRegen,
config.PlayerEsRegen, config.PlayerEsRechargeDelay)
{
- Position = new Vector2(sx * gridToWorld, sy * gridToWorld),
+ Position = dungeon.StartWorldPos,
};
- // Spawn initial enemies
- SpawnEnemies(config.TargetEnemyCount);
+ // Spawn enemies inside dungeon rooms
+ SpawnEnemiesInRooms(dungeon.Rooms);
}
public void RegenerateTerrain()
{
- Terrain = TerrainGenerator.Generate(_config.TerrainWidth, _config.TerrainHeight);
- var gridToWorld = 1f / _config.WorldToGrid;
- var (sx, sy) = TerrainGenerator.FindSpawnPosition(Terrain);
- Player.Position = new Vector2(sx * gridToWorld, sy * gridToWorld);
+ _dungeonSeed = _rng.Next();
+ var dungeon = TerrainGenerator.GenerateDungeon(
+ _config.TerrainWidth, _config.TerrainHeight, _dungeonSeed,
+ _config.DungeonRoomCountMin, _config.DungeonRoomCountMax,
+ _config.DungeonRoomSizeMin, _config.DungeonRoomSizeMax,
+ _config.DungeonCorridorWidth, _config.WorldToGrid);
+
+ Terrain = dungeon.Terrain;
+ StartWorldPos = dungeon.StartWorldPos;
+ EndWorldPos = dungeon.EndWorldPos;
+ ReachedEnd = false;
+
+ Player.Position = dungeon.StartWorldPos;
Player.Health = Player.MaxHealth;
Player.Mana = Player.MaxMana;
Player.Es = Player.MaxEs;
@@ -58,7 +79,8 @@ public class SimWorld
Projectiles.Clear();
ActiveEffects.Clear();
_respawnQueue.Clear();
- SpawnEnemies(_config.TargetEnemyCount);
+ SpawnEnemiesInRooms(dungeon.Rooms);
+ Log.Information("Dungeon regenerated (seed={Seed}), {Rooms} rooms", _dungeonSeed, dungeon.Rooms.Count);
}
public void QueueSkill(ushort scanCode, Vector2 targetWorldPos)
@@ -73,8 +95,12 @@ public class SimWorld
dt *= _config.SpeedMultiplier;
TickNumber++;
- // 0. Expand terrain if player near edge
- CheckAndExpandTerrain();
+ // 0. Check if player reached dungeon end
+ if (!ReachedEnd && Vector2.Distance(Player.Position, EndWorldPos) < _config.DungeonEndReachDist)
+ {
+ ReachedEnd = true;
+ Log.Information("Reached dungeon end!");
+ }
// 1. Move player
MovePlayer(dt);
@@ -508,26 +534,20 @@ public class SimWorld
var (timer, rarity) = _respawnQueue[i];
timer -= dt;
if (timer <= 0)
+ {
+ // Respawn at random walkable position in dungeon
+ var pos = TerrainGenerator.FindRandomWalkable(Terrain, _rng);
+ if (pos.HasValue)
+ {
+ var gridToWorld = 1f / _config.WorldToGrid;
+ SpawnEnemyAt(new Vector2(pos.Value.x * gridToWorld, pos.Value.y * gridToWorld), rarity);
+ }
_respawnQueue.RemoveAt(i);
+ }
else
+ {
_respawnQueue[i] = (timer, rarity);
- }
-
- // Cull enemies too far from player
- for (var i = Enemies.Count - 1; i >= 0; i--)
- {
- var dist = Vector2.Distance(Enemies[i].Position, Player.Position);
- if (dist > _config.EnemyCullDist)
- Enemies.RemoveAt(i);
- }
-
- // Maintain population
- var aliveCount = Enemies.Count(e => e.IsAlive);
- while (aliveCount < _config.TargetEnemyCount)
- {
- var spawned = SpawnGroup(RollRarity());
- aliveCount += spawned;
- if (spawned == 0) break;
+ }
}
}
@@ -548,91 +568,30 @@ public class SimWorld
Enemies.Add(enemy);
}
- private void SpawnEnemies(int count)
+ private void SpawnEnemiesInRooms(List<(int x, int y, int w, int h)> rooms)
{
- while (count > 0)
+ var gridToWorld = 1f / _config.WorldToGrid;
+ var totalSpawned = 0;
+
+ foreach (var (rx, ry, rw, rh) in rooms)
{
- var spawned = SpawnGroup(RollRarity());
- if (spawned == 0) break;
- count -= spawned;
- }
- }
+ var roomCenter = new Vector2((rx + rw / 2f) * gridToWorld, (ry + rh / 2f) * gridToWorld);
- private int SpawnGroup(MonsterRarity leaderRarity)
- {
- var center = FindSpawnNearPlayer();
- if (center is null) return 0;
+ // Skip the start room — don't spawn enemies right on the player
+ if (Vector2.Distance(roomCenter, StartWorldPos) < 50f) continue;
- var groupSize = _rng.Next(_config.EnemyGroupMin, _config.EnemyGroupMax + 1);
- var spawned = 0;
-
- for (var i = 0; i < groupSize; i++)
- {
- var rarity = i == 0 ? leaderRarity : MonsterRarity.White;
- var offset = i == 0
- ? Vector2.Zero
- : new Vector2(
- (_rng.NextSingle() - 0.5f) * 2f * _config.EnemyGroupSpread,
- (_rng.NextSingle() - 0.5f) * 2f * _config.EnemyGroupSpread);
-
- var pos = center.Value + offset;
- var gx = (int)(pos.X * _config.WorldToGrid);
- var gy = (int)(pos.Y * _config.WorldToGrid);
- if (Terrain.IsWalkable(gx, gy))
+ var groupSize = _rng.Next(_config.EnemyGroupMin, _config.EnemyGroupMax + 1);
+ for (var i = 0; i < groupSize && totalSpawned < _config.TargetEnemyCount; i++)
{
+ var lx = rx + _rng.Next(rw);
+ var ly = ry + _rng.Next(rh);
+ if (!Terrain.IsWalkable(lx, ly)) continue;
+ var pos = new Vector2(lx * gridToWorld, ly * gridToWorld);
+ var rarity = i == 0 ? RollRarity() : MonsterRarity.White;
SpawnEnemyAt(pos, rarity);
- spawned++;
+ totalSpawned++;
}
}
-
- return spawned;
- }
-
- private Vector2? FindSpawnNearPlayer()
- {
- var baseAngle = MoveDirection.LengthSquared() > 0.01f
- ? MathF.Atan2(MoveDirection.Y, MoveDirection.X)
- : _rng.NextSingle() * MathF.Tau;
-
- for (var attempt = 0; attempt < 30; attempt++)
- {
- var angle = baseAngle + (_rng.NextSingle() - 0.5f) * MathF.PI;
- var dist = _config.EnemySpawnMinDist + _rng.NextSingle() * (_config.EnemySpawnMaxDist - _config.EnemySpawnMinDist);
- var pos = Player.Position + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * dist;
-
- var gx = (int)(pos.X * _config.WorldToGrid);
- var gy = (int)(pos.Y * _config.WorldToGrid);
- if (Terrain.IsWalkable(gx, gy))
- return pos;
- }
-
- return null;
- }
-
- private void CheckAndExpandTerrain()
- {
- var gx = (int)(Player.Position.X * _config.WorldToGrid);
- var gy = (int)(Player.Position.Y * _config.WorldToGrid);
- var t = Terrain;
-
- var distLeft = gx - t.OffsetX;
- var distRight = (t.OffsetX + t.Width - 1) - gx;
- var distTop = gy - t.OffsetY;
- var distBottom = (t.OffsetY + t.Height - 1) - gy;
-
- var amt = _config.ExpandAmount;
- var expandLeft = distLeft < _config.ExpandThreshold ? amt : 0;
- var expandRight = distRight < _config.ExpandThreshold ? amt : 0;
- var expandTop = distTop < _config.ExpandThreshold ? amt : 0;
- var expandBottom = distBottom < _config.ExpandThreshold ? amt : 0;
-
- if (expandLeft > 0 || expandRight > 0 || expandTop > 0 || expandBottom > 0)
- {
- Terrain = TerrainGenerator.Expand(Terrain, expandLeft, expandRight, expandTop, expandBottom, _rng);
- Serilog.Log.Information(
- "Terrain expanded: {W}x{H} offset=({Ox},{Oy})",
- Terrain.Width, Terrain.Height, Terrain.OffsetX, Terrain.OffsetY);
- }
}
private MonsterRarity RollRarity()
diff --git a/src/Nexus.Simulator/World/TerrainGenerator.cs b/src/Nexus.Simulator/World/TerrainGenerator.cs
index 950b64b..0c7dc1e 100644
--- a/src/Nexus.Simulator/World/TerrainGenerator.cs
+++ b/src/Nexus.Simulator/World/TerrainGenerator.cs
@@ -3,6 +3,12 @@ using Nexus.Core;
namespace Nexus.Simulator.World;
+public record DungeonResult(
+ WalkabilitySnapshot Terrain,
+ Vector2 StartWorldPos,
+ Vector2 EndWorldPos,
+ List<(int x, int y, int w, int h)> Rooms);
+
public static class TerrainGenerator
{
// Permutation table for Perlin noise (fixed seed for deterministic terrain)
@@ -22,6 +28,277 @@ public static class TerrainGenerator
for (var i = 0; i < 512; i++) Perm[i] = p[i & 255];
}
+ public static DungeonResult GenerateDungeon(int width, int height, int seed,
+ int roomCountMin = 8, int roomCountMax = 15,
+ int roomSizeMin = 6, int roomSizeMax = 20,
+ int corridorWidth = 3, float worldToGrid = 23f / 250f)
+ {
+ var rng = new Random(seed);
+ var data = new byte[width * height]; // all walls by default
+
+ // 1. Place rooms
+ var rooms = new List<(int x, int y, int w, int h)>();
+ var targetCount = rng.Next(roomCountMin, roomCountMax + 1);
+
+ for (var attempt = 0; attempt < targetCount * 50 && rooms.Count < targetCount; attempt++)
+ {
+ var rw = rng.Next(roomSizeMin, roomSizeMax + 1);
+ var rh = rng.Next(roomSizeMin, roomSizeMax + 1);
+ // Place rooms in the central third of the grid so they stay close together
+ var margin = width / 3;
+ var rx = rng.Next(margin, width - rw - margin);
+ var ry = rng.Next(margin, height - rh - margin);
+
+ // Reject only if >50% overlap with an existing room (allow touching/partial overlap)
+ var overlaps = false;
+ foreach (var (ox, oy, ow, oh) in rooms)
+ {
+ var overlapX = Math.Max(0, Math.Min(rx + rw, ox + ow) - Math.Max(rx, ox));
+ var overlapY = Math.Max(0, Math.Min(ry + rh, oy + oh) - Math.Max(ry, oy));
+ var overlapArea = overlapX * overlapY;
+ var smallerArea = Math.Min(rw * rh, ow * oh);
+ if (overlapArea > smallerArea * 0.5f)
+ {
+ overlaps = true;
+ break;
+ }
+ }
+ if (overlaps) continue;
+
+ rooms.Add((rx, ry, rw, rh));
+
+ // Carve room
+ for (var ly = ry; ly < ry + rh; ly++)
+ for (var lx = rx; lx < rx + rw; lx++)
+ data[ly * width + lx] = 1;
+ }
+
+ if (rooms.Count < 2)
+ {
+ // Fallback: ensure at least 2 rooms
+ rooms.Clear();
+ var r1 = (x: width / 4, y: height / 4, w: roomSizeMax, h: roomSizeMax);
+ var r2 = (x: width * 3 / 4 - roomSizeMax, y: height * 3 / 4 - roomSizeMax, w: roomSizeMax, h: roomSizeMax);
+ rooms.Add(r1);
+ rooms.Add(r2);
+ for (var ly = r1.y; ly < r1.y + r1.h; ly++)
+ for (var lx = r1.x; lx < r1.x + r1.w; lx++)
+ data[ly * width + lx] = 1;
+ for (var ly = r2.y; ly < r2.y + r2.h; ly++)
+ for (var lx = r2.x; lx < r2.x + r2.w; lx++)
+ data[ly * width + lx] = 1;
+ }
+
+ // 2. Build MST using Prim's algorithm on room center distances
+ var roomCount = rooms.Count;
+ var inMst = new bool[roomCount];
+ var mstEdges = new List<(int a, int b)>();
+ inMst[0] = true;
+
+ while (mstEdges.Count < roomCount - 1)
+ {
+ var bestDist = float.MaxValue;
+ var bestA = -1;
+ var bestB = -1;
+
+ for (var a = 0; a < roomCount; a++)
+ {
+ if (!inMst[a]) continue;
+ for (var b = 0; b < roomCount; b++)
+ {
+ if (inMst[b]) continue;
+ var (ax, ay, aw, ah) = rooms[a];
+ var (bx, by, bw, bh) = rooms[b];
+ var dx = (ax + aw / 2f) - (bx + bw / 2f);
+ var dy = (ay + ah / 2f) - (by + bh / 2f);
+ var dist = dx * dx + dy * dy;
+ if (dist < bestDist)
+ {
+ bestDist = dist;
+ bestA = a;
+ bestB = b;
+ }
+ }
+ }
+
+ if (bestB < 0) break; // disconnected (shouldn't happen)
+ inMst[bestB] = true;
+ mstEdges.Add((bestA, bestB));
+ }
+
+ // Add 1-2 extra edges for loops
+ var extraEdges = Math.Min(2, roomCount / 3);
+ for (var i = 0; i < extraEdges; i++)
+ {
+ var a = rng.Next(roomCount);
+ var b = rng.Next(roomCount);
+ if (a != b && !mstEdges.Contains((a, b)) && !mstEdges.Contains((b, a)))
+ mstEdges.Add((a, b));
+ }
+
+ // 3. Carve L-shaped corridors between connected rooms
+ foreach (var (a, b) in mstEdges)
+ {
+ var (ax, ay, aw, ah) = rooms[a];
+ var (bx, by, bw, bh) = rooms[b];
+ var cx1 = ax + aw / 2;
+ var cy1 = ay + ah / 2;
+ var cx2 = bx + bw / 2;
+ var cy2 = by + bh / 2;
+
+ // Carve horizontal then vertical, with widened corner
+ CarveCorridor(data, width, height, cx1, cy1, cx2, cy1, corridorWidth);
+ CarveCorridor(data, width, height, cx2, cy1, cx2, cy2, corridorWidth);
+
+ // Widen the corner with a square patch so the bot doesn't clip walls
+ var cornerR = corridorWidth;
+ for (var dy2 = -cornerR; dy2 <= cornerR; dy2++)
+ for (var dx2 = -cornerR; dx2 <= cornerR; dx2++)
+ {
+ var px = cx2 + dx2;
+ var py = cy1 + dy2;
+ if (px >= 0 && px < width && py >= 0 && py < height)
+ data[py * width + px] = 1;
+ }
+ }
+
+ // 4. Build adjacency list from MST edges for BFS
+ var adj = new List[roomCount];
+ for (var i = 0; i < roomCount; i++) adj[i] = [];
+ foreach (var (a, b) in mstEdges)
+ {
+ adj[a].Add(b);
+ adj[b].Add(a);
+ }
+
+ // 5. Select start room: closest to grid center
+ var gridCx = width / 2f;
+ var gridCy = height / 2f;
+ var startRoom = 0;
+ var bestStartDist = float.MaxValue;
+ for (var i = 0; i < roomCount; i++)
+ {
+ var (rx, ry, rw, rh) = rooms[i];
+ var dx = (rx + rw / 2f) - gridCx;
+ var dy = (ry + rh / 2f) - gridCy;
+ var d = dx * dx + dy * dy;
+ if (d < bestStartDist) { bestStartDist = d; startRoom = i; }
+ }
+
+ // 6. Select end room: farthest from start via BFS graph distance
+ var bfsDist = new int[roomCount];
+ Array.Fill(bfsDist, -1);
+ bfsDist[startRoom] = 0;
+ var bfsQueue = new Queue();
+ bfsQueue.Enqueue(startRoom);
+ var endRoom = startRoom;
+ var maxDist = 0;
+
+ while (bfsQueue.Count > 0)
+ {
+ var cur = bfsQueue.Dequeue();
+ foreach (var nb in adj[cur])
+ {
+ if (bfsDist[nb] >= 0) continue;
+ bfsDist[nb] = bfsDist[cur] + 1;
+ if (bfsDist[nb] > maxDist) { maxDist = bfsDist[nb]; endRoom = nb; }
+ bfsQueue.Enqueue(nb);
+ }
+ }
+
+ // 7. Flood-fill connectivity check from start room — add corridors if any room unreachable
+ EnsureConnectivity(data, width, height, rooms, startRoom, corridorWidth);
+
+ var terrain = new WalkabilitySnapshot { Width = width, Height = height, Data = data };
+
+ // Convert room centers to world positions
+ var g2w = 1f / worldToGrid;
+ var (sx, sy, sw, sh) = rooms[startRoom];
+ var startWorld = new Vector2((sx + sw / 2f) * g2w, (sy + sh / 2f) * g2w);
+ var (ex, ey, ew, eh) = rooms[endRoom];
+ var endWorld = new Vector2((ex + ew / 2f) * g2w, (ey + eh / 2f) * g2w);
+
+ return new DungeonResult(terrain, startWorld, endWorld, rooms);
+ }
+
+ private static void CarveCorridor(byte[] data, int width, int height,
+ int x1, int y1, int x2, int y2, int thickness)
+ {
+ var half = thickness / 2;
+
+ if (y1 == y2)
+ {
+ // Horizontal
+ var minX = Math.Min(x1, x2);
+ var maxX = Math.Max(x1, x2);
+ for (var x = minX; x <= maxX; x++)
+ for (var dy = -half; dy <= half; dy++)
+ {
+ var y = y1 + dy;
+ if (x >= 0 && x < width && y >= 0 && y < height)
+ data[y * width + x] = 1;
+ }
+ }
+ else
+ {
+ // Vertical
+ var minY = Math.Min(y1, y2);
+ var maxY = Math.Max(y1, y2);
+ for (var y = minY; y <= maxY; y++)
+ for (var dx = -half; dx <= half; dx++)
+ {
+ var x = x1 + dx;
+ if (x >= 0 && x < width && y >= 0 && y < height)
+ data[y * width + x] = 1;
+ }
+ }
+ }
+
+ private static void EnsureConnectivity(byte[] data, int width, int height,
+ List<(int x, int y, int w, int h)> rooms, int startRoom, int corridorWidth)
+ {
+ // Flood-fill from start room center
+ var (sx, sy, sw, sh) = rooms[startRoom];
+ var seedX = sx + sw / 2;
+ var seedY = sy + sh / 2;
+
+ var visited = new bool[width * height];
+ var queue = new Queue<(int x, int y)>();
+ queue.Enqueue((seedX, seedY));
+ visited[seedY * width + seedX] = true;
+
+ while (queue.Count > 0)
+ {
+ var (cx, cy) = queue.Dequeue();
+ int[] dx = [-1, 1, 0, 0];
+ int[] dy = [0, 0, -1, 1];
+ for (var d = 0; d < 4; d++)
+ {
+ var nx = cx + dx[d];
+ var ny = cy + dy[d];
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
+ var idx = ny * width + nx;
+ if (visited[idx] || data[idx] == 0) continue;
+ visited[idx] = true;
+ queue.Enqueue((nx, ny));
+ }
+ }
+
+ // Check each room — if its center isn't reachable, carve a corridor to start
+ for (var i = 0; i < rooms.Count; i++)
+ {
+ if (i == startRoom) continue;
+ var (rx, ry, rw, rh) = rooms[i];
+ var rcx = rx + rw / 2;
+ var rcy = ry + rh / 2;
+ if (visited[rcy * width + rcx]) continue;
+
+ // Carve corridor from this room to start room
+ CarveCorridor(data, width, height, rcx, rcy, seedX, rcy, corridorWidth);
+ CarveCorridor(data, width, height, seedX, rcy, seedX, seedY, corridorWidth);
+ }
+ }
+
public static WalkabilitySnapshot Generate(int width, int height, int? seed = null)
{
var data = new byte[width * height];
diff --git a/src/Nexus.Systems/BotTick.cs b/src/Nexus.Systems/BotTick.cs
index cc58374..53e5921 100644
--- a/src/Nexus.Systems/BotTick.cs
+++ b/src/Nexus.Systems/BotTick.cs
@@ -1,3 +1,4 @@
+using System.Numerics;
using Nexus.Core;
using Nexus.Data;
using Nexus.Pathfinding;
@@ -18,6 +19,11 @@ public static class BotTick
actionQueue.Clear();
movementBlender.Clear();
+
+ // Update stuck detection BEFORE systems run so IsStuck is current
+ if (state.Player.HasPosition)
+ movementBlender.UpdateStuckState(state.Player.Position);
+
nav.Update(state);
foreach (var sys in systems)
@@ -25,16 +31,58 @@ public static class BotTick
sys.Update(state, actionQueue, movementBlender);
// Wall repulsion — push away from nearby walls to prevent getting stuck
+ // Layer 5: never dropped by stuck detection (unlike L2 orbit)
if (state.Terrain is { } terrain && state.Player.HasPosition)
{
var wallPush = TerrainQuery.ComputeWallRepulsion(terrain, state.Player.Position, config.WorldToGrid);
if (wallPush.LengthSquared() > 0.0001f)
- movementBlender.Submit(new MovementIntent(2, wallPush * 0.6f, 0.3f, "WallPush"));
+ {
+ // Boost wall push when stuck
+ var mag = movementBlender.IsStuck ? 1.0f : 0.6f;
+ var ovr = movementBlender.IsStuck ? 0.8f : 0.5f;
+ movementBlender.Submit(new MovementIntent(5, wallPush * mag, ovr, "WallPush"));
+ }
}
- if (nav.DesiredDirection.HasValue)
+ // Combat engagement: when visible enemies are nearby and we're not fleeing,
+ // navigate toward them instead of the exit. If fleeing, use normal nav to support escape.
+ var shouldEngage = false;
+ if (state.ThreatAssessment is not { ShouldFlee: true })
+ {
+ var enemyCentroid = Vector2.Zero;
+ var enemyCount = 0;
+ foreach (var m in state.HostileMonsters)
+ {
+ if (!m.IsAlive || m.DistanceToPlayer >= config.CombatEngagementRange) continue;
+ if (state.Terrain is { } los &&
+ !TerrainQuery.HasLineOfSight(los, state.Player.Position, m.Position, config.WorldToGrid))
+ continue;
+ enemyCentroid += m.Position;
+ enemyCount++;
+ }
+
+ if (enemyCount > 0 && state.Player.HasPosition)
+ {
+ shouldEngage = true;
+ enemyCentroid /= enemyCount;
+ var toEnemies = enemyCentroid - state.Player.Position;
+ if (toEnemies.LengthSquared() > 1f)
+ movementBlender.Submit(new MovementIntent(3, Vector2.Normalize(toEnemies), 0f, "Navigation"));
+ }
+ }
+
+ if (!shouldEngage && nav.DesiredDirection.HasValue)
movementBlender.Submit(new MovementIntent(3, nav.DesiredDirection.Value, 0f, "Navigation"));
+ // Predictive wall steering — look ahead along movement direction and steer around walls
+ if (!shouldEngage && state.Terrain is { } t2 && state.Player.HasPosition && nav.DesiredDirection.HasValue)
+ {
+ var steer = TerrainQuery.ComputeWallSteering(t2, state.Player.Position,
+ nav.DesiredDirection.Value, config.WorldToGrid);
+ if (steer.LengthSquared() > 0.0001f)
+ movementBlender.Submit(new MovementIntent(4, steer, 0.4f, "WallSteer"));
+ }
+
movementBlender.Resolve(state.Terrain, state.Player.Position, config.WorldToGrid);
var resolved = actionQueue.Resolve();
diff --git a/src/Nexus.Systems/MovementSystem.cs b/src/Nexus.Systems/MovementSystem.cs
index 6ffeb4c..3651368 100644
--- a/src/Nexus.Systems/MovementSystem.cs
+++ b/src/Nexus.Systems/MovementSystem.cs
@@ -22,7 +22,7 @@ public class MovementSystem : ISystem
public float WorldToGrid { get; set; } = 23f / 250f;
/// Minimum distance before radial push kicks in hard.
- public float MinComfortDistance { get; set; } = 150f;
+ public float MinComfortDistance { get; set; } = 80f;
private int _orbitSign = 1;
@@ -33,7 +33,7 @@ public class MovementSystem : ISystem
var playerPos = state.Player.Position;
- // Compute weighted centroid and closest distance of nearby hostiles
+ // Compute weighted centroid and closest distance of nearby hostiles (LOS only)
var centroid = Vector2.Zero;
var count = 0;
var closestDist = float.MaxValue;
@@ -42,6 +42,9 @@ public class MovementSystem : ISystem
{
if (!monster.IsAlive) continue;
if (monster.DistanceToPlayer > SafeDistance) continue;
+ if (state.Terrain is { } t &&
+ !TerrainQuery.HasLineOfSight(t, playerPos, monster.Position, WorldToGrid))
+ continue;
centroid += monster.Position;
count++;
@@ -62,14 +65,14 @@ public class MovementSystem : ISystem
var tangent = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign;
// Radial component — push away from centroid, strength based on proximity
- // Close < MinComfort: strong push out
- // MinComfort..SafeDistance*0.5: gentle push out
+ // Close < MinComfort: gentle push out (avoid stacking on top of enemies)
+ // MinComfort..SafeDistance*0.5: slight push out
// SafeDistance*0.7+: pull inward to maintain engagement instead of drifting away
float radialStrength;
if (closestDist < MinComfortDistance)
- radialStrength = -0.6f; // too close — push outward
+ radialStrength = -0.25f; // too close — gentle push outward
else if (closestDist < SafeDistance * 0.5f)
- radialStrength = -0.3f; // somewhat close — moderate push outward
+ radialStrength = -0.1f; // somewhat close — slight push outward
else if (closestDist > SafeDistance * 0.7f)
radialStrength = 0.4f; // at edge — pull inward to maintain engagement
else
@@ -81,8 +84,12 @@ public class MovementSystem : ISystem
if (result.LengthSquared() < 0.0001f) return;
// Override: attenuate navigation (layer 3) when actively orbiting enemies.
- // Without this, navigation at full weight pulls the bot past enemies.
- float orbitOverride = closestDist < SafeDistance * 0.7f ? 0.8f : 0.5f;
+ // Keep moderate so navigation can still guide past walls.
+ float orbitOverride = closestDist < SafeDistance * 0.7f ? 0.5f : 0.3f;
+
+ // Suppress orbit when blender detects stuck — let navigation guide out
+ if (movement.IsStuck)
+ return;
movement.Submit(new MovementIntent(2, Vector2.Normalize(result) * RepulsionWeight, orbitOverride, "Orbit"));
}
diff --git a/src/Nexus.Systems/ThreatSystem.cs b/src/Nexus.Systems/ThreatSystem.cs
index 7dacac3..744333f 100644
--- a/src/Nexus.Systems/ThreatSystem.cs
+++ b/src/Nexus.Systems/ThreatSystem.cs
@@ -320,7 +320,7 @@ public class ThreatSystem : ISystem
if (state.Terrain is { } terrain)
{
hasLos = TerrainQuery.HasLineOfSight(terrain, playerPos, monster.Position, WorldToGrid);
- if (!hasLos) score *= 0.4f;
+ if (!hasLos) score *= 0.05f;
}
// Low HP monsters are less threatening