test
This commit is contained in:
parent
703cfbfdee
commit
8ca257bc79
15 changed files with 577 additions and 147 deletions
|
|
@ -9,7 +9,7 @@ Size=432,649
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Simulator]
|
[Window][Simulator]
|
||||||
Pos=499,177
|
Pos=564,96
|
||||||
Size=1200,681
|
Size=893,571
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ public class BotConfig
|
||||||
// Navigation
|
// Navigation
|
||||||
public float WorldToGrid { get; set; } = 23f / 250f;
|
public float WorldToGrid { get; set; } = 23f / 250f;
|
||||||
|
|
||||||
|
// Combat engagement — suppress navigation when enemies are within this range
|
||||||
|
public float CombatEngagementRange { get; set; } = 600f;
|
||||||
|
|
||||||
// Loot
|
// Loot
|
||||||
public float LootPickupRange { get; set; } = 600f;
|
public float LootPickupRange { get; set; } = 600f;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ public sealed class MovementBlender
|
||||||
// Stuck detection
|
// Stuck detection
|
||||||
private Vector2 _lastResolvePos;
|
private Vector2 _lastResolvePos;
|
||||||
private int _stuckFrames;
|
private int _stuckFrames;
|
||||||
private const int StuckFrameThreshold = 30; // ~0.5s at 60Hz
|
private const int StuckFrameThreshold = 15; // ~250ms at 60Hz
|
||||||
private const float StuckMovePerFrame = 3f; // must move > 3 world units per frame to count as moving
|
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.
|
// EMA smoothing to dampen terrain validation jitter.
|
||||||
// Snap decision based on INTENT change (pre-terrain), not terrain output — prevents
|
// 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();
|
public void Clear() => _intents.Clear();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Blends all submitted intents and validates against terrain.
|
/// Updates stuck detection based on player movement. Call BEFORE systems run
|
||||||
/// Applies EMA smoothing after terrain validation to dampen probe jitter.
|
/// so that IsStuck is available for systems to check (e.g. MovementSystem suppresses orbit).
|
||||||
/// </summary>
|
/// </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);
|
var moved = Vector2.Distance(playerPos, _lastResolvePos);
|
||||||
if (moved < StuckMovePerFrame)
|
if (moved < StuckMovePerFrame)
|
||||||
_stuckFrames++;
|
_stuckFrames++;
|
||||||
|
|
@ -74,11 +72,31 @@ public sealed class MovementBlender
|
||||||
_stuckFrames = Math.Max(0, _stuckFrames - 3); // recover 3x faster than building up
|
_stuckFrames = Math.Max(0, _stuckFrames - 3); // recover 3x faster than building up
|
||||||
_lastResolvePos = playerPos;
|
_lastResolvePos = playerPos;
|
||||||
IsStuck = _stuckFrames > StuckFrameThreshold;
|
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)
|
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);
|
_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);
|
_lastIntents = new List<MovementIntent>(_intents);
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,8 @@ public static class TerrainQuery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Vector2 ComputeWallRepulsion(WalkabilitySnapshot terrain, Vector2 playerPos, float worldToGrid)
|
public static Vector2 ComputeWallRepulsion(WalkabilitySnapshot terrain, Vector2 playerPos, float worldToGrid)
|
||||||
{
|
{
|
||||||
const float probeNear = 25f; // ~2-3 grid cells
|
const float probeNear = 40f; // ~3-4 grid cells
|
||||||
const float probeFar = 60f; // ~5-6 grid cells
|
const float probeFar = 100f; // ~9-10 grid cells
|
||||||
|
|
||||||
var push = Vector2.Zero;
|
var push = Vector2.Zero;
|
||||||
|
|
||||||
|
|
@ -122,6 +122,62 @@ public static class TerrainQuery
|
||||||
return Vector2.Normalize(push);
|
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)
|
private static Vector2 Rotate(Vector2 v, float degrees)
|
||||||
{
|
{
|
||||||
float rad = degrees * MathF.PI / 180f;
|
float rad = degrees * MathF.PI / 180f;
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,8 @@ public sealed class NavigationController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Log.Debug("NavigationController: stuck detected, repathing");
|
Log.Debug("NavigationController: stuck detected, repathing");
|
||||||
|
_positionHistory.Clear();
|
||||||
|
_stuckGraceTicks = 120; // 2 seconds grace before next stuck check
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,15 @@ public static class PathFinder
|
||||||
stepCost *= 1.5f;
|
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;
|
var tentativeG = currentG + stepCost;
|
||||||
|
|
||||||
if (tentativeG < gScore.GetValueOrDefault(neighbor, float.MaxValue))
|
if (tentativeG < gScore.GetValueOrDefault(neighbor, float.MaxValue))
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,18 @@ namespace Nexus.Simulator.Config;
|
||||||
public class SimConfig
|
public class SimConfig
|
||||||
{
|
{
|
||||||
// Terrain
|
// Terrain
|
||||||
public int TerrainWidth { get; set; } = 500;
|
public int TerrainWidth { get; set; } = 1500;
|
||||||
public int TerrainHeight { get; set; } = 500;
|
public int TerrainHeight { get; set; } = 1500;
|
||||||
public float WorldToGrid { get; set; } = 23f / 250f;
|
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
|
// Player
|
||||||
public float PlayerMoveSpeed { get; set; } = 400f;
|
public float PlayerMoveSpeed { get; set; } = 400f;
|
||||||
public int PlayerMaxHealth { get; set; } = 800;
|
public int PlayerMaxHealth { get; set; } = 800;
|
||||||
|
|
@ -55,10 +63,6 @@ public class SimConfig
|
||||||
public float ProjectileHitRadius { get; set; } = 80f;
|
public float ProjectileHitRadius { get; set; } = 80f;
|
||||||
public int SkillBaseDamage { get; set; } = 200;
|
public int SkillBaseDamage { get; set; } = 200;
|
||||||
|
|
||||||
// Terrain expansion
|
|
||||||
public int ExpandThreshold { get; set; } = 50;
|
|
||||||
public int ExpandAmount { get; set; } = 250;
|
|
||||||
|
|
||||||
// Simulation
|
// Simulation
|
||||||
public float SpeedMultiplier { get; set; } = 1f;
|
public float SpeedMultiplier { get; set; } = 1f;
|
||||||
public bool IsPaused { get; set; }
|
public bool IsPaused { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,8 @@ foreach (var sys in systems)
|
||||||
// ── Start simulation poller ──
|
// ── Start simulation poller ──
|
||||||
poller.Start();
|
poller.Start();
|
||||||
|
|
||||||
// ── Start exploring ──
|
// ── Navigate to dungeon end ──
|
||||||
nav.Explore();
|
nav.NavigateTo(world.EndWorldPos);
|
||||||
|
|
||||||
// ── Bot logic thread ──
|
// ── Bot logic thread ──
|
||||||
var actionQueue = new ActionQueue();
|
var actionQueue = new ActionQueue();
|
||||||
|
|
@ -98,6 +98,13 @@ var botThread = new Thread(() =>
|
||||||
var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
|
var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
|
||||||
ActionExecutor.Execute(resolved, input, moveTracker, movementBlender, state.Player.Position);
|
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++;
|
botTickCount++;
|
||||||
if (maxTicks.HasValue && botTickCount >= maxTicks.Value)
|
if (maxTicks.HasValue && botTickCount >= maxTicks.Value)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,8 @@ public class SimRenderer
|
||||||
// 1. Terrain
|
// 1. Terrain
|
||||||
TerrainRenderer.Draw(drawList, _world.Terrain, vt, canvasSize,
|
TerrainRenderer.Draw(drawList, _world.Terrain, vt, canvasSize,
|
||||||
_nav.ExploredGrid, _nav.ExploredWidth, _nav.ExploredHeight,
|
_nav.ExploredGrid, _nav.ExploredWidth, _nav.ExploredHeight,
|
||||||
_nav.ExploredOffsetX, _nav.ExploredOffsetY);
|
_nav.ExploredOffsetX, _nav.ExploredOffsetY,
|
||||||
|
_world.StartWorldPos, _world.EndWorldPos);
|
||||||
|
|
||||||
// 2. Path
|
// 2. Path
|
||||||
PathRenderer.Draw(drawList, _nav, vt);
|
PathRenderer.Draw(drawList, _nav, vt);
|
||||||
|
|
@ -93,7 +94,10 @@ public class SimRenderer
|
||||||
var minimapSize = 150f;
|
var minimapSize = 150f;
|
||||||
var minimapOrigin = canvasOrigin + canvasSize - new Vector2(minimapSize + 10, minimapSize + 10);
|
var minimapOrigin = canvasOrigin + canvasSize - new Vector2(minimapSize + 10, minimapSize + 10);
|
||||||
var playerGridPos = _world.Player.Position * _config.WorldToGrid;
|
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
|
// HUD text
|
||||||
DrawHud(drawList, canvasOrigin, state);
|
DrawHud(drawList, canvasOrigin, state);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ public static class TerrainRenderer
|
||||||
public static void Draw(ImDrawListPtr drawList, WalkabilitySnapshot terrain,
|
public static void Draw(ImDrawListPtr drawList, WalkabilitySnapshot terrain,
|
||||||
ViewTransform vt, Vector2 canvasSize,
|
ViewTransform vt, Vector2 canvasSize,
|
||||||
bool[]? exploredGrid = null, int exploredWidth = 0, int exploredHeight = 0,
|
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;
|
var cellSize = vt.Zoom;
|
||||||
if (cellSize < 0.5f) return;
|
if (cellSize < 0.5f) return;
|
||||||
|
|
@ -71,13 +72,32 @@ public static class TerrainRenderer
|
||||||
var p3 = vt.GridToScreen(gx, gy + step); // left
|
var p3 = vt.GridToScreen(gx, gy + step); // left
|
||||||
drawList.AddQuadFilled(p0, p1, p2, p3, color);
|
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>
|
/// <summary>
|
||||||
/// Draws a minimap in the corner (top-down, no rotation).
|
/// Draws a minimap in the corner (top-down, no rotation).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static void DrawMinimap(ImDrawListPtr drawList, WalkabilitySnapshot terrain,
|
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 scaleX = minimapSize / terrain.Width;
|
||||||
var scaleY = minimapSize / terrain.Height;
|
var scaleY = minimapSize / terrain.Height;
|
||||||
|
|
@ -102,10 +122,26 @@ public static class TerrainRenderer
|
||||||
0xFF2A2A3F);
|
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 playerLocalPos = playerGridPos - new Vector2(terrain.OffsetX, terrain.OffsetY);
|
||||||
var playerPx = minimapOrigin + playerLocalPos * scale;
|
var playerPx = minimapOrigin + playerLocalPos * scale;
|
||||||
drawList.AddCircleFilled(playerPx, 3f, 0xFF00FF00);
|
drawList.AddCircleFilled(playerPx, 3f, 0xFFFFFF00); // cyan (ABGR)
|
||||||
|
|
||||||
// Border
|
// Border
|
||||||
drawList.AddRect(minimapOrigin,
|
drawList.AddRect(minimapOrigin,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ public class SimWorld
|
||||||
public List<SimSkillEffect> ActiveEffects { get; } = [];
|
public List<SimSkillEffect> ActiveEffects { get; } = [];
|
||||||
public WalkabilitySnapshot Terrain { get; private set; }
|
public WalkabilitySnapshot Terrain { get; private set; }
|
||||||
public long TickNumber { 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
|
// Pending respawns
|
||||||
private readonly List<(float timer, MonsterRarity rarity)> _respawnQueue = [];
|
private readonly List<(float timer, MonsterRarity rarity)> _respawnQueue = [];
|
||||||
|
|
@ -28,29 +33,45 @@ public class SimWorld
|
||||||
public SimWorld(SimConfig config)
|
public SimWorld(SimConfig config)
|
||||||
{
|
{
|
||||||
_config = 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(
|
Player = new SimPlayer(
|
||||||
config.PlayerMaxHealth, config.PlayerMaxMana, config.PlayerMaxEs,
|
config.PlayerMaxHealth, config.PlayerMaxMana, config.PlayerMaxEs,
|
||||||
config.PlayerMoveSpeed, config.PlayerHealthRegen, config.PlayerManaRegen,
|
config.PlayerMoveSpeed, config.PlayerHealthRegen, config.PlayerManaRegen,
|
||||||
config.PlayerEsRegen, config.PlayerEsRechargeDelay)
|
config.PlayerEsRegen, config.PlayerEsRechargeDelay)
|
||||||
{
|
{
|
||||||
Position = new Vector2(sx * gridToWorld, sy * gridToWorld),
|
Position = dungeon.StartWorldPos,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spawn initial enemies
|
// Spawn enemies inside dungeon rooms
|
||||||
SpawnEnemies(config.TargetEnemyCount);
|
SpawnEnemiesInRooms(dungeon.Rooms);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RegenerateTerrain()
|
public void RegenerateTerrain()
|
||||||
{
|
{
|
||||||
Terrain = TerrainGenerator.Generate(_config.TerrainWidth, _config.TerrainHeight);
|
_dungeonSeed = _rng.Next();
|
||||||
var gridToWorld = 1f / _config.WorldToGrid;
|
var dungeon = TerrainGenerator.GenerateDungeon(
|
||||||
var (sx, sy) = TerrainGenerator.FindSpawnPosition(Terrain);
|
_config.TerrainWidth, _config.TerrainHeight, _dungeonSeed,
|
||||||
Player.Position = new Vector2(sx * gridToWorld, sy * gridToWorld);
|
_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.Health = Player.MaxHealth;
|
||||||
Player.Mana = Player.MaxMana;
|
Player.Mana = Player.MaxMana;
|
||||||
Player.Es = Player.MaxEs;
|
Player.Es = Player.MaxEs;
|
||||||
|
|
@ -58,7 +79,8 @@ public class SimWorld
|
||||||
Projectiles.Clear();
|
Projectiles.Clear();
|
||||||
ActiveEffects.Clear();
|
ActiveEffects.Clear();
|
||||||
_respawnQueue.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)
|
public void QueueSkill(ushort scanCode, Vector2 targetWorldPos)
|
||||||
|
|
@ -73,8 +95,12 @@ public class SimWorld
|
||||||
dt *= _config.SpeedMultiplier;
|
dt *= _config.SpeedMultiplier;
|
||||||
TickNumber++;
|
TickNumber++;
|
||||||
|
|
||||||
// 0. Expand terrain if player near edge
|
// 0. Check if player reached dungeon end
|
||||||
CheckAndExpandTerrain();
|
if (!ReachedEnd && Vector2.Distance(Player.Position, EndWorldPos) < _config.DungeonEndReachDist)
|
||||||
|
{
|
||||||
|
ReachedEnd = true;
|
||||||
|
Log.Information("Reached dungeon end!");
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Move player
|
// 1. Move player
|
||||||
MovePlayer(dt);
|
MovePlayer(dt);
|
||||||
|
|
@ -508,26 +534,20 @@ public class SimWorld
|
||||||
var (timer, rarity) = _respawnQueue[i];
|
var (timer, rarity) = _respawnQueue[i];
|
||||||
timer -= dt;
|
timer -= dt;
|
||||||
if (timer <= 0)
|
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);
|
_respawnQueue.RemoveAt(i);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
_respawnQueue[i] = (timer, rarity);
|
_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);
|
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());
|
var roomCenter = new Vector2((rx + rw / 2f) * gridToWorld, (ry + rh / 2f) * gridToWorld);
|
||||||
if (spawned == 0) break;
|
|
||||||
count -= spawned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int SpawnGroup(MonsterRarity leaderRarity)
|
// Skip the start room — don't spawn enemies right on the player
|
||||||
{
|
if (Vector2.Distance(roomCenter, StartWorldPos) < 50f) continue;
|
||||||
var center = FindSpawnNearPlayer();
|
|
||||||
if (center is null) return 0;
|
|
||||||
|
|
||||||
var groupSize = _rng.Next(_config.EnemyGroupMin, _config.EnemyGroupMax + 1);
|
var groupSize = _rng.Next(_config.EnemyGroupMin, _config.EnemyGroupMax + 1);
|
||||||
var spawned = 0;
|
for (var i = 0; i < groupSize && totalSpawned < _config.TargetEnemyCount; i++)
|
||||||
|
|
||||||
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 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);
|
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()
|
private MonsterRarity RollRarity()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ using Nexus.Core;
|
||||||
|
|
||||||
namespace Nexus.Simulator.World;
|
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
|
public static class TerrainGenerator
|
||||||
{
|
{
|
||||||
// Permutation table for Perlin noise (fixed seed for deterministic terrain)
|
// 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];
|
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)
|
public static WalkabilitySnapshot Generate(int width, int height, int? seed = null)
|
||||||
{
|
{
|
||||||
var data = new byte[width * height];
|
var data = new byte[width * height];
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Numerics;
|
||||||
using Nexus.Core;
|
using Nexus.Core;
|
||||||
using Nexus.Data;
|
using Nexus.Data;
|
||||||
using Nexus.Pathfinding;
|
using Nexus.Pathfinding;
|
||||||
|
|
@ -18,6 +19,11 @@ public static class BotTick
|
||||||
|
|
||||||
actionQueue.Clear();
|
actionQueue.Clear();
|
||||||
movementBlender.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);
|
nav.Update(state);
|
||||||
|
|
||||||
foreach (var sys in systems)
|
foreach (var sys in systems)
|
||||||
|
|
@ -25,16 +31,58 @@ public static class BotTick
|
||||||
sys.Update(state, actionQueue, movementBlender);
|
sys.Update(state, actionQueue, movementBlender);
|
||||||
|
|
||||||
// Wall repulsion — push away from nearby walls to prevent getting stuck
|
// 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)
|
if (state.Terrain is { } terrain && state.Player.HasPosition)
|
||||||
{
|
{
|
||||||
var wallPush = TerrainQuery.ComputeWallRepulsion(terrain, state.Player.Position, config.WorldToGrid);
|
var wallPush = TerrainQuery.ComputeWallRepulsion(terrain, state.Player.Position, config.WorldToGrid);
|
||||||
if (wallPush.LengthSquared() > 0.0001f)
|
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"));
|
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);
|
movementBlender.Resolve(state.Terrain, state.Player.Position, config.WorldToGrid);
|
||||||
var resolved = actionQueue.Resolve();
|
var resolved = actionQueue.Resolve();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ public class MovementSystem : ISystem
|
||||||
public float WorldToGrid { get; set; } = 23f / 250f;
|
public float WorldToGrid { get; set; } = 23f / 250f;
|
||||||
|
|
||||||
/// <summary>Minimum distance before radial push kicks in hard.</summary>
|
/// <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;
|
private int _orbitSign = 1;
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ public class MovementSystem : ISystem
|
||||||
|
|
||||||
var playerPos = state.Player.Position;
|
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 centroid = Vector2.Zero;
|
||||||
var count = 0;
|
var count = 0;
|
||||||
var closestDist = float.MaxValue;
|
var closestDist = float.MaxValue;
|
||||||
|
|
@ -42,6 +42,9 @@ public class MovementSystem : ISystem
|
||||||
{
|
{
|
||||||
if (!monster.IsAlive) continue;
|
if (!monster.IsAlive) continue;
|
||||||
if (monster.DistanceToPlayer > SafeDistance) continue;
|
if (monster.DistanceToPlayer > SafeDistance) continue;
|
||||||
|
if (state.Terrain is { } t &&
|
||||||
|
!TerrainQuery.HasLineOfSight(t, playerPos, monster.Position, WorldToGrid))
|
||||||
|
continue;
|
||||||
|
|
||||||
centroid += monster.Position;
|
centroid += monster.Position;
|
||||||
count++;
|
count++;
|
||||||
|
|
@ -62,14 +65,14 @@ public class MovementSystem : ISystem
|
||||||
var tangent = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign;
|
var tangent = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign;
|
||||||
|
|
||||||
// Radial component — push away from centroid, strength based on proximity
|
// Radial component — push away from centroid, strength based on proximity
|
||||||
// Close < MinComfort: strong push out
|
// Close < MinComfort: gentle push out (avoid stacking on top of enemies)
|
||||||
// MinComfort..SafeDistance*0.5: gentle push out
|
// MinComfort..SafeDistance*0.5: slight push out
|
||||||
// SafeDistance*0.7+: pull inward to maintain engagement instead of drifting away
|
// SafeDistance*0.7+: pull inward to maintain engagement instead of drifting away
|
||||||
float radialStrength;
|
float radialStrength;
|
||||||
if (closestDist < MinComfortDistance)
|
if (closestDist < MinComfortDistance)
|
||||||
radialStrength = -0.6f; // too close — push outward
|
radialStrength = -0.25f; // too close — gentle push outward
|
||||||
else if (closestDist < SafeDistance * 0.5f)
|
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)
|
else if (closestDist > SafeDistance * 0.7f)
|
||||||
radialStrength = 0.4f; // at edge — pull inward to maintain engagement
|
radialStrength = 0.4f; // at edge — pull inward to maintain engagement
|
||||||
else
|
else
|
||||||
|
|
@ -81,8 +84,12 @@ public class MovementSystem : ISystem
|
||||||
if (result.LengthSquared() < 0.0001f) return;
|
if (result.LengthSquared() < 0.0001f) return;
|
||||||
|
|
||||||
// Override: attenuate navigation (layer 3) when actively orbiting enemies.
|
// Override: attenuate navigation (layer 3) when actively orbiting enemies.
|
||||||
// Without this, navigation at full weight pulls the bot past enemies.
|
// Keep moderate so navigation can still guide past walls.
|
||||||
float orbitOverride = closestDist < SafeDistance * 0.7f ? 0.8f : 0.5f;
|
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"));
|
movement.Submit(new MovementIntent(2, Vector2.Normalize(result) * RepulsionWeight, orbitOverride, "Orbit"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,7 @@ public class ThreatSystem : ISystem
|
||||||
if (state.Terrain is { } terrain)
|
if (state.Terrain is { } terrain)
|
||||||
{
|
{
|
||||||
hasLos = TerrainQuery.HasLineOfSight(terrain, playerPos, monster.Position, WorldToGrid);
|
hasLos = TerrainQuery.HasLineOfSight(terrain, playerPos, monster.Position, WorldToGrid);
|
||||||
if (!hasLos) score *= 0.4f;
|
if (!hasLos) score *= 0.05f;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Low HP monsters are less threatening
|
// Low HP monsters are less threatening
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue