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