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
|
||||
|
||||
[Window][Simulator]
|
||||
Pos=499,177
|
||||
Size=1200,681
|
||||
Pos=564,96
|
||||
Size=893,571
|
||||
Collapsed=0
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue