This commit is contained in:
Boki 2026-03-07 14:38:20 -05:00
parent 703cfbfdee
commit 8ca257bc79
15 changed files with 577 additions and 147 deletions

View file

@ -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

View file

@ -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;

View file

@ -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();
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// Blends all submitted intents and validates against terrain.
/// Applies EMA smoothing after terrain validation to dampen probe jitter.
/// </summary>
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<MovementIntent>(_intents);

View file

@ -88,8 +88,8 @@ public static class TerrainQuery
/// </summary>
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);
}
/// <summary>
/// Predictive wall steering — casts rays ahead along the movement direction.
/// If forward is blocked but a side is clear, returns a lateral steering vector.
/// </summary>
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<float> 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;

View file

@ -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
}
}

View file

@ -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))

View file

@ -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; }

View file

@ -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)
{

View file

@ -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);

View file

@ -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);
}
}
/// <summary>
/// Draws a minimap in the corner (top-down, no rotation).
/// </summary>
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,

View file

@ -16,6 +16,11 @@ public class SimWorld
public List<SimSkillEffect> 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()

View file

@ -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<int>[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<int>();
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];

View file

@ -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();

View file

@ -22,7 +22,7 @@ public class MovementSystem : ISystem
public float WorldToGrid { get; set; } = 23f / 250f;
/// <summary>Minimum distance before radial push kicks in hard.</summary>
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"));
}

View file

@ -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