From f09ee5d1062fc4fb0cc5718bbb5e601493f8f790 Mon Sep 17 00:00:00 2001 From: Boki Date: Sat, 4 Apr 2026 16:44:32 -0400 Subject: [PATCH] work on sim bot --- imgui.ini | 2 +- src/Nexus.Core/ActionExecutor.cs | 39 ++++- src/Nexus.Core/Actions.cs | 2 + src/Nexus.Core/Enums.cs | 1 + src/Nexus.Core/GameState.cs | 2 + src/Nexus.Core/HandModel.cs | 110 ++++++++++++ src/Nexus.Core/IInputController.cs | 8 + src/Nexus.Core/MovementBlender.cs | 12 +- src/Nexus.Core/MovementKeyTracker.cs | 5 + src/Nexus.Core/PlayerState.cs | 4 + src/Nexus.Core/ProjectileSnapshot.cs | 16 ++ src/Nexus.Pathfinding/NavigationController.cs | 45 +++++ .../Bridge/SimInputController.cs | 23 +++ src/Nexus.Simulator/Bridge/SimStateBuilder.cs | 112 ++++++++++++- src/Nexus.Simulator/Config/SimConfig.cs | 5 + src/Nexus.Simulator/Program.cs | 10 +- .../Rendering/EntityRenderer.cs | 52 +++++- .../Rendering/InputOverlayRenderer.cs | 17 +- src/Nexus.Simulator/Rendering/SimRenderer.cs | 36 +++- src/Nexus.Simulator/World/SimItem.cs | 15 ++ src/Nexus.Simulator/World/SimPlayer.cs | 20 +++ src/Nexus.Simulator/World/SimWorld.cs | 157 +++++++++++++++++- src/Nexus.Systems/AreaProgressionSystem.cs | 53 ++++-- src/Nexus.Systems/BotTick.cs | 20 ++- src/Nexus.Systems/DodgeSystem.cs | 93 +++++++++++ src/Nexus.Systems/LootSystem.cs | 35 +++- src/Nexus.Systems/MovementSystem.cs | 21 ++- src/Nexus.Systems/SystemFactory.cs | 1 + src/Nexus.Systems/ThreatSystem.cs | 33 ++-- 29 files changed, 889 insertions(+), 60 deletions(-) create mode 100644 src/Nexus.Core/HandModel.cs create mode 100644 src/Nexus.Core/ProjectileSnapshot.cs create mode 100644 src/Nexus.Simulator/World/SimItem.cs create mode 100644 src/Nexus.Systems/DodgeSystem.cs diff --git a/imgui.ini b/imgui.ini index 5af8267..9c98818 100644 --- a/imgui.ini +++ b/imgui.ini @@ -10,6 +10,6 @@ Collapsed=0 [Window][Simulator] Pos=564,96 -Size=893,571 +Size=1023,810 Collapsed=0 diff --git a/src/Nexus.Core/ActionExecutor.cs b/src/Nexus.Core/ActionExecutor.cs index 11b3ec6..0325b67 100644 --- a/src/Nexus.Core/ActionExecutor.cs +++ b/src/Nexus.Core/ActionExecutor.cs @@ -4,11 +4,26 @@ namespace Nexus.Core; public static class ActionExecutor { + // Screen center (half of 2560x1440) + private const float ScreenCenterX = 1280f; + private const float ScreenCenterY = 720f; + + // How far ahead of the player (in screen pixels) the idle cursor sits + private const float IdleCursorDistance = 200f; + public static void Execute(List resolved, IInputController input, - MovementKeyTracker moveTracker, MovementBlender blender, Vector2? playerPos = null) + MovementKeyTracker moveTracker, MovementBlender blender, Vector2? playerPos = null, + Matrix4x4? camera = null) { if (!input.IsInitialized) return; + var hasCast = false; + + // Filter out physically impossible key combos (same finger) + resolved = HandModel.Filter(resolved, + moveTracker.IsWHeld, moveTracker.IsAHeld, + moveTracker.IsSHeld, moveTracker.IsDHeld); + // Discrete actions foreach (var action in resolved) { @@ -19,6 +34,7 @@ public static class ActionExecutor break; case CastAction cast: + hasCast = true; if (cast.TargetScreenPos.HasValue) input.SmoothMoveTo((int)cast.TargetScreenPos.Value.X, (int)cast.TargetScreenPos.Value.Y); input.KeyPress(cast.SkillScanCode); @@ -43,6 +59,27 @@ public static class ActionExecutor case KeyActionType.Up: input.KeyUp(key.ScanCode); break; } break; + + case DodgeRollAction dodge: + input.SetDodgeDirection(dodge.Direction); + input.KeyPress(0x39); // Space bar + break; + } + } + + // Idle mouse tracking: when not casting, keep cursor ahead of player in movement direction. + // This prevents jarring jumps from target to target and gives smooth cursor flow. + if (!hasCast && blender.Direction is { } moveDir && camera.HasValue && playerPos.HasValue) + { + // Project a point slightly ahead of the player in the movement direction + var aheadWorld = playerPos.Value + moveDir * 300f; + var screenAhead = WorldToScreen.Project(aheadWorld, 0f, camera.Value); + if (screenAhead.HasValue) + { + // Clamp to reasonable screen bounds + var sx = Math.Clamp(screenAhead.Value.X, 100f, 2460f); + var sy = Math.Clamp(screenAhead.Value.Y, 100f, 1340f); + input.SmoothMoveTo((int)sx, (int)sy); } } diff --git a/src/Nexus.Core/Actions.cs b/src/Nexus.Core/Actions.cs index 453c5f5..35bf7d7 100644 --- a/src/Nexus.Core/Actions.cs +++ b/src/Nexus.Core/Actions.cs @@ -25,3 +25,5 @@ public record FlaskAction(int Priority, ushort FlaskScanCode) : BotAction(Priori public record ChatAction(int Priority, string Message) : BotAction(Priority); public record WaitAction(int Priority, int DurationMs) : BotAction(Priority); + +public record DodgeRollAction(int Priority, Vector2 Direction) : BotAction(Priority); diff --git a/src/Nexus.Core/Enums.cs b/src/Nexus.Core/Enums.cs index 1a41bea..57915a7 100644 --- a/src/Nexus.Core/Enums.cs +++ b/src/Nexus.Core/Enums.cs @@ -12,6 +12,7 @@ public enum DangerLevel public static class SystemPriority { public const int Threat = 50; + public const int Dodge = 75; public const int Movement = 100; public const int Navigation = 200; public const int Combat = 300; diff --git a/src/Nexus.Core/GameState.cs b/src/Nexus.Core/GameState.cs index e7125c0..f1de0b4 100644 --- a/src/Nexus.Core/GameState.cs +++ b/src/Nexus.Core/GameState.cs @@ -27,6 +27,8 @@ public class GameState /// In-progress quests from the quest linked list with target areas and paths. public IReadOnlyList Quests { get; set; } = []; + public IReadOnlyList EnemyProjectiles { get; set; } = []; + // Derived (computed once per tick by GameStateEnricher / ThreatSystem) public ThreatMap Threats { get; set; } = new(); public ThreatAssessment ThreatAssessment { get; set; } = new(); diff --git a/src/Nexus.Core/HandModel.cs b/src/Nexus.Core/HandModel.cs new file mode 100644 index 0000000..ed02730 --- /dev/null +++ b/src/Nexus.Core/HandModel.cs @@ -0,0 +1,110 @@ +namespace Nexus.Core; + +public enum Finger { Pinky, Ring, Middle, Index, Thumb } + +/// +/// Maps left-hand keys to physical fingers and filters out actions that would +/// require the same finger simultaneously. Dropped actions retry next tick (16ms). +/// Right hand (mouse) is unconstrained. +/// +public static class HandModel +{ + private static readonly Dictionary FingerMap = new() + { + // Pinky: 1, Q, A + [ScanCodes.Key1] = Finger.Pinky, + [ScanCodes.Q] = Finger.Pinky, + [ScanCodes.A] = Finger.Pinky, + + // Ring: 2, W, S + [ScanCodes.Key2] = Finger.Ring, + [ScanCodes.W] = Finger.Ring, + [ScanCodes.S] = Finger.Ring, + + // Middle: 3, E, D + [ScanCodes.Key3] = Finger.Middle, + [ScanCodes.E] = Finger.Middle, + [ScanCodes.D] = Finger.Middle, + + // Index: 4, 5, R, T, F + [ScanCodes.Key4] = Finger.Index, + [ScanCodes.Key5] = Finger.Index, + [ScanCodes.R] = Finger.Index, + [ScanCodes.T] = Finger.Index, + [ScanCodes.F] = Finger.Index, + + // Thumb: Space, LAlt + [ScanCodes.Space] = Finger.Thumb, + [ScanCodes.LAlt] = Finger.Thumb, + }; + + // Lower = higher priority when two actions compete for the same finger + private static int ActionTypePriority(BotAction a) => a switch + { + DodgeRollAction => 0, + FlaskAction => 1, + CastAction => 2, + KeyAction => 3, + _ => 4, + }; + + public static List Filter(List resolved, + bool wHeld, bool aHeld, bool sHeld, bool dHeld) + { + // Build occupied set from currently held WASD keys + var occupied = new HashSet(); + if (wHeld) occupied.Add(Finger.Ring); + if (aHeld) occupied.Add(Finger.Pinky); + if (sHeld) occupied.Add(Finger.Ring); + if (dHeld) occupied.Add(Finger.Middle); + + // Sort by action type priority (dodge > flask > cast > key) + resolved.Sort((a, b) => ActionTypePriority(a).CompareTo(ActionTypePriority(b))); + + var result = new List(resolved.Count); + + foreach (var action in resolved) + { + var scanCode = GetScanCode(action); + + // No scan code (ClickAction, ChatAction, WaitAction, MoveAction) → always pass + if (scanCode is null) + { + result.Add(action); + continue; + } + + // Key releases always pass — they free a finger + if (action is KeyAction { Type: KeyActionType.Up }) + { + result.Add(action); + continue; + } + + // No finger mapping for this scan code → pass (right-hand or unmapped key) + if (!FingerMap.TryGetValue(scanCode.Value, out var finger)) + { + result.Add(action); + continue; + } + + // Finger free → accept and mark occupied + if (occupied.Add(finger)) + { + result.Add(action); + } + // else: finger already occupied → drop, will retry next tick + } + + return result; + } + + private static ushort? GetScanCode(BotAction action) => action switch + { + DodgeRollAction => ScanCodes.Space, + FlaskAction f => f.FlaskScanCode, + CastAction c => c.SkillScanCode, + KeyAction k => k.ScanCode, + _ => null, + }; +} diff --git a/src/Nexus.Core/IInputController.cs b/src/Nexus.Core/IInputController.cs index 394e3c1..6b92156 100644 --- a/src/Nexus.Core/IInputController.cs +++ b/src/Nexus.Core/IInputController.cs @@ -1,3 +1,5 @@ +using System.Numerics; + namespace Nexus.Core; public interface IInputController @@ -16,4 +18,10 @@ public interface IInputController void LeftUp(); void RightDown(); void RightUp(); + + /// + /// Sets the direction for the next dodge roll. Called before KeyPress(0x21). + /// Default no-op for real input controllers (direction comes from game state). + /// + void SetDodgeDirection(Vector2 direction) { } } diff --git a/src/Nexus.Core/MovementBlender.cs b/src/Nexus.Core/MovementBlender.cs index 87bce56..d05a239 100644 --- a/src/Nexus.Core/MovementBlender.cs +++ b/src/Nexus.Core/MovementBlender.cs @@ -84,15 +84,19 @@ public sealed class MovementBlender if (IsStuck) { - // Keep only flee (L0, L1), navigation (L3), and wall push (L5) — drop orbit (L2) and herd (L4) + // Drop orbit (L2) and herd (L4) — they don't help when stuck _intents.RemoveAll(i => i.Layer == 2 || i.Layer == 4); - // After 1s stuck, inject a random perpendicular nudge to break free + // Drop flee (L0, L1) too — if we're stuck, flee is pointing into a wall. + // Let wall push and navigation guide us out instead. + _intents.RemoveAll(i => i.Layer <= 1); + + // After 750ms stuck, inject a random nudge at high priority 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")); + var nudge = new Vector2((float)Math.Cos(angle), (float)Math.Sin(angle)); + _intents.Add(new MovementIntent(0, nudge, 0.6f, "StuckEscape")); // Reset counter so we try a new direction periodically if (_stuckFrames % 30 == 0) _stuckFrames = StuckRecoveryThreshold + 1; diff --git a/src/Nexus.Core/MovementKeyTracker.cs b/src/Nexus.Core/MovementKeyTracker.cs index 59204d1..9470e01 100644 --- a/src/Nexus.Core/MovementKeyTracker.cs +++ b/src/Nexus.Core/MovementKeyTracker.cs @@ -12,6 +12,11 @@ namespace Nexus.Core; public sealed class MovementKeyTracker { private bool _wHeld, _aHeld, _sHeld, _dHeld; + + public bool IsWHeld => _wHeld; + public bool IsAHeld => _aHeld; + public bool IsSHeld => _sHeld; + public bool IsDHeld => _dHeld; private long _wDownAt, _aDownAt, _sDownAt, _dDownAt; private int _wMinHold, _aMinHold, _sMinHold, _dMinHold; private long _wUpAt, _aUpAt, _sUpAt, _dUpAt; diff --git a/src/Nexus.Core/PlayerState.cs b/src/Nexus.Core/PlayerState.cs index bcd5f66..f9e02fa 100644 --- a/src/Nexus.Core/PlayerState.cs +++ b/src/Nexus.Core/PlayerState.cs @@ -31,4 +31,8 @@ public record PlayerState // Skill slots (populated by memory when available) public IReadOnlyList Skills { get; init; } = []; + + // Dodge roll state + public bool IsRolling { get; init; } + public float RollCooldownRemaining { get; init; } } diff --git a/src/Nexus.Core/ProjectileSnapshot.cs b/src/Nexus.Core/ProjectileSnapshot.cs new file mode 100644 index 0000000..91f549c --- /dev/null +++ b/src/Nexus.Core/ProjectileSnapshot.cs @@ -0,0 +1,16 @@ +using System.Numerics; + +namespace Nexus.Core; + +public record ProjectileSnapshot +{ + public Vector2 Position { get; init; } + public Vector2 Direction { get; init; } + public float Speed { get; init; } + public float HitRadius { get; init; } + public float DistanceToPlayer { get; init; } + /// Seconds until impact. Null if projectile will miss. + public float? TimeToImpact { get; init; } + /// Closest distance the projectile's trajectory passes to the player center. + public float ClosestApproachDistance { get; init; } +} diff --git a/src/Nexus.Pathfinding/NavigationController.cs b/src/Nexus.Pathfinding/NavigationController.cs index ea929d3..71fd322 100644 --- a/src/Nexus.Pathfinding/NavigationController.cs +++ b/src/Nexus.Pathfinding/NavigationController.cs @@ -42,6 +42,14 @@ public sealed class NavigationController // Grace period after picking a new explore target — don't check stuck immediately private int _stuckGraceTicks; + // Repeated stuck detection — force random walk after multiple failures at same spot + private int _repeatedStuckCount; + private Vector2 _lastStuckPos; + private const float RepeatedStuckRadius = 300f; // same-area detection + private const int RepeatedStuckLimit = 3; // failures before random walk + private int _randomWalkTicks; // countdown for forced random direction + private Vector2 _randomWalkDir; + public NavMode Mode { get; private set; } = NavMode.Idle; public Vector2? DesiredDirection { get; private set; } public IReadOnlyList? CurrentPath => _path; @@ -139,6 +147,8 @@ public sealed class NavigationController _exploreBiasPoint = null; _exploredGrid = null; _pathFailCooldownMs = 0; + _repeatedStuckCount = 0; + _randomWalkTicks = 0; IsExplorationComplete = false; } @@ -195,6 +205,20 @@ public sealed class NavigationController if (_stuckGraceTicks > 0) _stuckGraceTicks--; + // Random walk override — forced escape from repeated stuck loops + if (_randomWalkTicks > 0) + { + _randomWalkTicks--; + DesiredDirection = _randomWalkDir; + Status = "Random walk (escape)"; + if (_randomWalkTicks == 0) + { + _path = null; // force repath after random walk + _positionHistory.Clear(); + } + return; + } + var isStuck = false; if (_stuckGraceTicks <= 0 && _positionHistory.Count >= StuckWindowSize && _path is not null) { @@ -202,6 +226,27 @@ public sealed class NavigationController if (Vector2.Distance(oldest, playerPos) < StuckThreshold) { isStuck = true; + + // Track repeated stuck at the same location + if (Vector2.Distance(playerPos, _lastStuckPos) < RepeatedStuckRadius) + _repeatedStuckCount++; + else + _repeatedStuckCount = 1; + _lastStuckPos = playerPos; + + if (_repeatedStuckCount >= RepeatedStuckLimit) + { + // Force random walk to break free + var angle = (float)(_rng.NextDouble() * Math.PI * 2); + _randomWalkDir = new Vector2(MathF.Cos(angle), MathF.Sin(angle)); + _randomWalkTicks = 120; // ~2 seconds of random walk + _repeatedStuckCount = 0; + Log.Information("NavigationController: repeated stuck at ({X:F0},{Y:F0}), forcing random walk", + playerPos.X, playerPos.Y); + DesiredDirection = _randomWalkDir; + return; + } + if (Mode == NavMode.Exploring) { Log.Information("NavigationController: stuck while exploring, picking new target"); diff --git a/src/Nexus.Simulator/Bridge/SimInputController.cs b/src/Nexus.Simulator/Bridge/SimInputController.cs index aed2596..6a1f9e6 100644 --- a/src/Nexus.Simulator/Bridge/SimInputController.cs +++ b/src/Nexus.Simulator/Bridge/SimInputController.cs @@ -28,6 +28,9 @@ public class SimInputController : IInputController private readonly float[] _mouseTimers = new float[3]; private const float FlashDuration = 0.3f; + // Dodge roll + private Vector2? _pendingDodgeDirection; + // Smooth mouse interpolation private Vector2 _mouseMoveStartPos; private Vector2 _mouseTargetPos; @@ -129,12 +132,32 @@ public class SimInputController : IInputController } } + public void SetDodgeDirection(Vector2 direction) + { + lock (_lock) { _pendingDodgeDirection = direction; } + } + public void KeyPress(ushort scanCode, int holdMs = 50) { lock (_lock) { _keyTimers[scanCode] = FlashDuration; } + + // Intercept dodge roll key (Space = 0x39) + if (scanCode == 0x39) + { + Vector2 dir; + lock (_lock) + { + dir = _pendingDodgeDirection ?? _world.MoveDirection; + _pendingDodgeDirection = null; + } + if (dir.LengthSquared() > 0.001f) + _world.QueueDodgeRoll(dir); + return; + } + // Queue as skill cast var target = ScreenToWorld(_mouseScreenPos); _world.QueueSkill(scanCode, target); diff --git a/src/Nexus.Simulator/Bridge/SimStateBuilder.cs b/src/Nexus.Simulator/Bridge/SimStateBuilder.cs index e441879..27bc482 100644 --- a/src/Nexus.Simulator/Bridge/SimStateBuilder.cs +++ b/src/Nexus.Simulator/Bridge/SimStateBuilder.cs @@ -18,6 +18,7 @@ public static class SimStateBuilder var entities = new List(); var hostiles = new List(); + var nearbyLoot = new List(); foreach (var enemy in world.Enemies) { @@ -44,8 +45,65 @@ public static class SimStateBuilder hostiles.Add(snap); } + // Add area transition entity at dungeon exit so BotTick exit avoidance works + var exitSnap = new EntitySnapshot + { + Id = uint.MaxValue, + Path = "Metadata/Terrain/AreaTransition", + Category = EntityCategory.AreaTransition, + Position = world.EndWorldPos, + Z = 0f, + DistanceToPlayer = Vector2.Distance(world.EndWorldPos, player.Position), + IsAlive = false, + IsTargetable = true, + TransitionName = "DungeonExit", + }; + entities.Add(exitSnap); + + // Build loot snapshots + foreach (var item in world.Items) + { + var (rarity, isQuest) = item.Category switch + { + LootCategory.Magic => (MonsterRarity.Magic, false), + LootCategory.Rare => (MonsterRarity.Rare, false), + LootCategory.Unique => (MonsterRarity.Unique, false), + LootCategory.Quest => (MonsterRarity.White, true), + _ => (MonsterRarity.White, false), + }; + + var label = item.Category == LootCategory.Currency + ? $"Currency:{item.Label}" + : item.Label; + + var snap = new EntitySnapshot + { + Id = item.Id, + Path = "Metadata/MiscellaneousObjects/WorldItem", + Category = EntityCategory.WorldItem, + Rarity = rarity, + Position = item.Position, + Z = 0f, + DistanceToPlayer = Vector2.Distance(item.Position, player.Position), + IsAlive = true, + IsTargetable = true, + ItemBaseName = label, + IsQuestItem = isQuest, + }; + entities.Add(snap); + + // Only add filtered items (currency/rare/unique/quest) to NearbyLoot + if (item.Category is LootCategory.Currency or LootCategory.Rare + or LootCategory.Unique or LootCategory.Quest) + { + nearbyLoot.Add(snap); + } + } + var cameraMatrix = BuildCameraMatrix(player.Position); + var projectiles = BuildProjectileSnapshots(world, player.Position); + return new GameState { TickNumber = _tickNumber, @@ -60,7 +118,8 @@ public static class SimStateBuilder Terrain = world.Terrain, Entities = entities, HostileMonsters = hostiles, - NearbyLoot = [], + NearbyLoot = nearbyLoot, + EnemyProjectiles = projectiles, Player = new PlayerState { CharacterName = "SimPlayer", @@ -74,6 +133,8 @@ public static class SimStateBuilder EsCurrent = player.Es, EsTotal = player.MaxEs, Skills = BuildSkillStates(), + IsRolling = player.IsRolling, + RollCooldownRemaining = player.RollCooldownRemaining, }, }; } @@ -104,6 +165,55 @@ public static class SimStateBuilder -playerPos.X / halfW, -playerPos.Y / halfH, 0, 1); } + private const float PlayerRadius = 20f; + + private static List BuildProjectileSnapshots(SimWorld world, Vector2 playerPos) + { + var snapshots = new List(); + + foreach (var proj in world.Projectiles) + { + if (!proj.IsEnemyProjectile || proj.IsExpired) continue; + + var toPlayer = playerPos - proj.Position; + var dist = toPlayer.Length(); + + // Dot product: how far ahead of the projectile the player is (along travel direction) + var dot = Vector2.Dot(toPlayer, proj.Direction); + if (dot < 0) continue; // Moving away from player + + // Perpendicular distance from player center to projectile trajectory line + var closestDist = MathF.Abs(toPlayer.X * proj.Direction.Y - toPlayer.Y * proj.Direction.X); + + var collisionRadius = proj.HitRadius + PlayerRadius; + float? timeToImpact = null; + + if (closestDist < collisionRadius) + { + // Will hit — compute entry time via circle-line intersection + var discriminant = collisionRadius * collisionRadius - closestDist * closestDist; + var entryDist = dot - MathF.Sqrt(discriminant); + if (entryDist > 0) + timeToImpact = entryDist / proj.Speed; + else + timeToImpact = 0f; // Already overlapping + } + + snapshots.Add(new ProjectileSnapshot + { + Position = proj.Position, + Direction = proj.Direction, + Speed = proj.Speed, + HitRadius = proj.HitRadius, + DistanceToPlayer = dist, + TimeToImpact = timeToImpact, + ClosestApproachDistance = closestDist, + }); + } + + return snapshots; + } + private static List BuildSkillStates() { return diff --git a/src/Nexus.Simulator/Config/SimConfig.cs b/src/Nexus.Simulator/Config/SimConfig.cs index 4724343..7ca9c8a 100644 --- a/src/Nexus.Simulator/Config/SimConfig.cs +++ b/src/Nexus.Simulator/Config/SimConfig.cs @@ -54,6 +54,11 @@ public class SimConfig public int EnemyGroupMax { get; set; } = 18; public float EnemyGroupSpread { get; set; } = 120f; + // Dodge roll + public float DodgeRollDistance { get; set; } = 100f; // world units traveled per roll + public float DodgeRollDuration { get; set; } = 0.25f; // 250ms + public float DodgeRollCooldown { get; set; } = 1.0f; // 1s between rolls + // Player skills public float MeleeRange { get; set; } = 350f; public float MeleeConeAngle { get; set; } = 120f; diff --git a/src/Nexus.Simulator/Program.cs b/src/Nexus.Simulator/Program.cs index 76b8a01..52a7d23 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(); -// ── Navigate to dungeon end ── -nav.NavigateTo(world.EndWorldPos); +// ── Explore the dungeon (not beeline to exit) ── +nav.Explore(); // ── Bot logic thread ── var actionQueue = new ActionQueue(); @@ -96,13 +96,13 @@ var botThread = new Thread(() => if (state is not null && !state.IsLoading && !state.IsEscapeOpen) { 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, state.CameraMatrix); - // Check if dungeon end reached — regenerate and re-navigate + // Check if dungeon end reached — regenerate and explore new dungeon if (world.ReachedEnd) { world.RegenerateTerrain(); - nav.NavigateTo(world.EndWorldPos); + nav.Explore(); } botTickCount++; diff --git a/src/Nexus.Simulator/Rendering/EntityRenderer.cs b/src/Nexus.Simulator/Rendering/EntityRenderer.cs index b099953..1cd1ee5 100644 --- a/src/Nexus.Simulator/Rendering/EntityRenderer.cs +++ b/src/Nexus.Simulator/Rendering/EntityRenderer.cs @@ -12,8 +12,19 @@ public static class EntityRenderer var screenPos = vt.WorldToScreen(player.Position); var radius = 8f; - drawList.AddCircleFilled(screenPos, radius, 0xFF00FF00); // Green - drawList.AddCircle(screenPos, radius + 1, 0xFF00AA00); + if (player.IsRolling) + { + drawList.AddCircleFilled(screenPos, radius, 0xFFFFAA00); // Cyan when rolling + drawList.AddCircle(screenPos, radius + 1, 0xFFFFDD44); + // "R" centered in the circle + var textSize = ImGui.CalcTextSize("R"); + drawList.AddText(screenPos - textSize * 0.5f, 0xFF000000, "R"); + } + else + { + drawList.AddCircleFilled(screenPos, radius, 0xFF00FF00); // Green + drawList.AddCircle(screenPos, radius + 1, 0xFF00AA00); + } var barY = radius + 8; @@ -95,6 +106,43 @@ public static class EntityRenderer } } + public static void DrawItems(ImDrawListPtr drawList, IReadOnlyList items, + ViewTransform vt, Vector2 canvasMin, Vector2 canvasMax) + { + foreach (var item in items) + { + var screenPos = vt.WorldToScreen(item.Position); + + // Cull off-screen + if (screenPos.X < canvasMin.X - 60 || screenPos.X > canvasMax.X + 60 || + screenPos.Y < canvasMin.Y - 20 || screenPos.Y > canvasMax.Y + 20) + continue; + + var color = item.Category switch + { + LootCategory.Currency => 0xFF0000FF, // Red + LootCategory.Magic => 0xFFFF8800, // Blue + LootCategory.Rare => 0xFF00FFFF, // Yellow + LootCategory.Unique => 0xFF00AAFF, // Orange + LootCategory.Quest => 0xFF00FF00, // Green + _ => 0xFFCCCCCC, // White + }; + + var textSize = ImGui.CalcTextSize(item.Label); + var labelPos = screenPos - new Vector2(textSize.X * 0.5f, 16f); + + // Background + var pad = new Vector2(3, 1); + drawList.AddRectFilled(labelPos - pad, labelPos + textSize + pad, 0xBB000000); + + // Label text + drawList.AddText(labelPos, color, item.Label); + + // Small dot at item position + drawList.AddCircleFilled(screenPos, 3f, color); + } + } + private static void DrawHealthBar(ImDrawListPtr drawList, Vector2 pos, float width, float height, int current, int max, uint color) { diff --git a/src/Nexus.Simulator/Rendering/InputOverlayRenderer.cs b/src/Nexus.Simulator/Rendering/InputOverlayRenderer.cs index 6695c88..5a6a6ce 100644 --- a/src/Nexus.Simulator/Rendering/InputOverlayRenderer.cs +++ b/src/Nexus.Simulator/Rendering/InputOverlayRenderer.cs @@ -24,6 +24,7 @@ public static class InputOverlayRenderer private const uint ScrollBg = 0xFF333333; private const uint CursorDot = 0xFF00DDFF; // Cyan dot for cursor position private const uint CrosshairColor = 0x44FFFFFF; // Dim crosshair + private const float SpaceBarHeight = 20f; // Keyboard rows: (label, scanCode, column offset) private static readonly (string L, ushort S, float C)[] Row0 = @@ -44,7 +45,7 @@ public static class InputOverlayRenderer { var padSize = 80f; var mouseH = 64f; - var kbH = 3 * Stride; + var kbH = 3 * Stride + SpaceBarHeight + Gap; var totalH = kbH + 6 + mouseH + 6 + padSize; var origin = canvasOrigin + new Vector2(15, canvasSize.Y - totalH - 15); @@ -52,6 +53,7 @@ public static class InputOverlayRenderer DrawKeyRow(drawList, origin, Row0, 0, input); DrawKeyRow(drawList, origin, Row1, 1, input); DrawKeyRow(drawList, origin, Row2, 2, input); + DrawSpaceBar(drawList, origin + new Vector2(0, 3 * Stride), input); // Mouse to the right of keyboard var kbW = 4.25f * Stride + KeySize; @@ -79,6 +81,19 @@ public static class InputOverlayRenderer } } + private static void DrawSpaceBar(ImDrawListPtr drawList, Vector2 origin, InputSnapshot input) + { + const ushort spaceScan = 0x39; + var on = input.IsKeyActive(spaceScan); + + // Wide bar spanning roughly the ASDF row width + var pos = origin + new Vector2(0.5f * Stride, 0); + var size = new Vector2(3.5f * Stride, SpaceBarHeight); + + drawList.AddRectFilled(pos, pos + size, on ? ActiveBg : DarkBg, 3f); + drawList.AddRect(pos, pos + size, on ? Yellow : Outline, 3f); + } + private static void DrawMouse(ImDrawListPtr drawList, InputSnapshot input, Vector2 o) { const float w = 44, h = 64, hw = w / 2, bh = 26; diff --git a/src/Nexus.Simulator/Rendering/SimRenderer.cs b/src/Nexus.Simulator/Rendering/SimRenderer.cs index 0338ea6..541f242 100644 --- a/src/Nexus.Simulator/Rendering/SimRenderer.cs +++ b/src/Nexus.Simulator/Rendering/SimRenderer.cs @@ -75,17 +75,24 @@ public class SimRenderer var effects = _world.ActiveEffects.ToArray(); var projectiles = _world.Projectiles.ToArray(); var enemies = _world.Enemies.ToArray(); + var items = _world.Items.ToArray(); EffectRenderer.DrawEffects(drawList, effects, vt); EffectRenderer.DrawProjectiles(drawList, projectiles, vt); - // 4. Enemies + // 4. Items (ground loot labels) + EntityRenderer.DrawItems(drawList, items, vt, canvasOrigin, canvasOrigin + canvasSize); + + // 5. Enemies EntityRenderer.DrawEnemies(drawList, enemies, vt, canvasOrigin, canvasOrigin + canvasSize); - // 5. Player + // 6. Player EntityRenderer.DrawPlayer(drawList, _world.Player, vt); - // 6. Mock cursor — shows where the bot's mouse is pointing in the world + // 7. Monitor viewport outline — shows what a 2560x1440 monitor would see + DrawMonitorBounds(drawList, vt); + + // 8. Mock cursor — shows where the bot's mouse is pointing in the world DrawMockCursor(drawList, vt); drawList.PopClipRect(); @@ -139,6 +146,29 @@ public class SimRenderer } } + /// + /// Draws an axis-aligned rectangle centered on the player showing the approximate + /// field of view of a 2560x1440 monitor. Scales with sim zoom. + /// + private void DrawMonitorBounds(ImDrawListPtr drawList, ViewTransform vt) + { + // Approximate visible world area on a real monitor (~1400x788 world units at 16:9). + // Aggro range (600) sits comfortably inside. + const float halfW = 700f; + const float halfH = 394f; + + var center = vt.WorldToScreen(_world.Player.Position); + var scale = vt.WorldScale; + var sw = halfW * scale; + var sh = halfH * scale; + + var tl = center + new Vector2(-sw, -sh); + var br = center + new Vector2(sw, sh); + + const uint color = 0x44AAAAAA; + drawList.AddRect(tl, br, color); + } + private void DrawMockCursor(ImDrawListPtr drawList, ViewTransform vt) { // Convert the bot's mouse screen position to world, then to our viewport diff --git a/src/Nexus.Simulator/World/SimItem.cs b/src/Nexus.Simulator/World/SimItem.cs new file mode 100644 index 0000000..ceda3e4 --- /dev/null +++ b/src/Nexus.Simulator/World/SimItem.cs @@ -0,0 +1,15 @@ +using System.Numerics; + +namespace Nexus.Simulator.World; + +public enum LootCategory { Currency, Normal, Magic, Rare, Unique, Quest } + +public class SimItem +{ + private static uint _nextId = 50000; + + public uint Id { get; } = Interlocked.Increment(ref _nextId); + public Vector2 Position { get; init; } + public LootCategory Category { get; init; } + public string Label { get; init; } = ""; +} diff --git a/src/Nexus.Simulator/World/SimPlayer.cs b/src/Nexus.Simulator/World/SimPlayer.cs index 5b409e1..26947a1 100644 --- a/src/Nexus.Simulator/World/SimPlayer.cs +++ b/src/Nexus.Simulator/World/SimPlayer.cs @@ -17,6 +17,13 @@ public class SimPlayer public float EsRegen { get; set; } public float EsRechargeDelay { get; set; } + // Dodge roll state + public bool IsRolling { get; set; } + public Vector2 RollDirection { get; set; } + public float RollElapsed { get; set; } + public float RollDuration { get; set; } + public float RollCooldownRemaining { get; set; } + // Accumulate fractional regen private float _healthRegenAccum; private float _manaRegenAccum; @@ -41,8 +48,21 @@ public class SimPlayer _timeSinceLastDamage = esRechargeDelay; // Start with ES recharging } + public void StartDodgeRoll(Vector2 direction, float duration) + { + if (IsRolling || RollCooldownRemaining > 0) return; + IsRolling = true; + RollDirection = Vector2.Normalize(direction); + RollElapsed = 0f; + RollDuration = duration; + } + public void Update(float dt) { + // Tick dodge cooldown + if (RollCooldownRemaining > 0) + RollCooldownRemaining = MathF.Max(0, RollCooldownRemaining - dt); + _timeSinceLastDamage += dt; // Health regen (always active) diff --git a/src/Nexus.Simulator/World/SimWorld.cs b/src/Nexus.Simulator/World/SimWorld.cs index 681d295..b12ae1e 100644 --- a/src/Nexus.Simulator/World/SimWorld.cs +++ b/src/Nexus.Simulator/World/SimWorld.cs @@ -14,6 +14,7 @@ public class SimWorld public List Enemies { get; } = []; public List Projectiles { get; } = []; public List ActiveEffects { get; } = []; + public List Items { get; } = []; public WalkabilitySnapshot Terrain { get; private set; } public long TickNumber { get; private set; } public Vector2 StartWorldPos { get; private set; } @@ -30,6 +31,10 @@ public class SimWorld public Vector2 MouseWorldPos { get; set; } private readonly Queue<(ushort scanCode, Vector2 targetWorldPos)> _skillQueue = new(); + // Dodge roll + private Vector2? _pendingDodgeDirection; + private Vector2 _lastFacingDirection = Vector2.UnitX; + public SimWorld(SimConfig config) { _config = config; @@ -78,6 +83,7 @@ public class SimWorld Enemies.Clear(); Projectiles.Clear(); ActiveEffects.Clear(); + Items.Clear(); _respawnQueue.Clear(); SpawnEnemiesInRooms(dungeon.Rooms); Log.Information("Dungeon regenerated (seed={Seed}), {Rooms} rooms", _dungeonSeed, dungeon.Rooms.Count); @@ -88,6 +94,11 @@ public class SimWorld _skillQueue.Enqueue((scanCode, targetWorldPos)); } + public void QueueDodgeRoll(Vector2 direction) + { + _pendingDodgeDirection = direction; + } + public void Tick(float dt) { if (_config.IsPaused) return; @@ -102,6 +113,17 @@ public class SimWorld Log.Information("Reached dungeon end!"); } + // 0.5. Start queued dodge roll + if (_pendingDodgeDirection.HasValue && !Player.IsRolling && Player.RollCooldownRemaining <= 0) + { + Player.StartDodgeRoll(_pendingDodgeDirection.Value, _config.DodgeRollDuration); + _pendingDodgeDirection = null; + } + else + { + _pendingDodgeDirection = null; + } + // 1. Move player MovePlayer(dt); @@ -120,7 +142,10 @@ public class SimWorld // 6. Process respawn queue UpdateRespawns(dt); - // 7. Player regen + // 7. Pickup items near player + PickupNearbyItems(); + + // 8. Player regen Player.Update(dt); } @@ -134,9 +159,17 @@ public class SimWorld private void MovePlayer(float dt) { + // Dodge roll overrides normal movement + if (Player.IsRolling) + { + MovePlayerDodgeRoll(dt); + return; + } + if (MoveDirection.LengthSquared() < 0.001f) return; var dir = Vector2.Normalize(MoveDirection); + _lastFacingDirection = dir; // Track for stationary dodge var step = Player.MoveSpeed * dt; // Try full direction @@ -166,6 +199,46 @@ public class SimWorld } } + /// + /// Ease-out dodge roll: v(t) = (2D/T) * (1 - t/T). + /// Peak speed at start = 2D/T, decelerates to 0 over duration T. + /// Total distance traveled = D (integral of v over [0,T]). + /// + private void MovePlayerDodgeRoll(float dt) + { + Player.RollElapsed += dt; + var t = Player.RollElapsed; + var T = Player.RollDuration; + var D = _config.DodgeRollDistance; + + if (t >= T) + { + // Roll complete + Player.IsRolling = false; + Player.RollCooldownRemaining = _config.DodgeRollCooldown; + return; + } + + // Quadratic ease-out speed: peaks at 2D/T, drops to 0 + var speed = (2f * D / T) * (1f - t / T); + var step = speed * dt; + var dir = Player.RollDirection; + + // Try full direction, then wall-slide fallback + if (!TryMove(dir, step)) + { + // Try axis-aligned slides + if (MathF.Abs(dir.X) > 0.01f && TryMove(new Vector2(dir.X > 0 ? 1 : -1, 0), step)) { } + else if (MathF.Abs(dir.Y) > 0.01f && TryMove(new Vector2(0, dir.Y > 0 ? 1 : -1), step)) { } + else + { + // Completely blocked — end roll early + Player.IsRolling = false; + Player.RollCooldownRemaining = _config.DodgeRollCooldown; + } + } + } + private bool TryMove(Vector2 dir, float step) { var newPos = Player.Position + dir * step; @@ -246,7 +319,10 @@ public class SimWorld enemy.TakeDamage(_config.SkillBaseDamage); if (!enemy.IsAlive) + { Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id); + SpawnLoot(enemy); + } } } @@ -269,7 +345,10 @@ public class SimWorld { enemy.TakeDamage(_config.SkillBaseDamage); if (!enemy.IsAlive) + { Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id); + SpawnLoot(enemy); + } } } } @@ -337,7 +416,10 @@ public class SimWorld { enemy.TakeDamage(proj.Damage); if (!enemy.IsAlive) + { Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id); + SpawnLoot(enemy); + } proj.IsExpired = true; break; } @@ -602,4 +684,77 @@ public class SimWorld if (roll < _config.UniqueChance + _config.RareChance + _config.MagicChance) return MonsterRarity.Magic; return MonsterRarity.White; } + + // --- Loot --- + + private void SpawnLoot(SimEnemy enemy) + { + var (dropChance, minItems, maxItems) = enemy.Rarity switch + { + MonsterRarity.Unique => (1f, 3, 5), + MonsterRarity.Rare => (0.8f, 2, 3), + MonsterRarity.Magic => (0.5f, 1, 2), + _ => (0.3f, 1, 1), + }; + + if (_rng.NextSingle() > dropChance) return; + + var count = _rng.Next(minItems, maxItems + 1); + for (var i = 0; i < count; i++) + { + var category = RollLootCategory(); + var offset = new Vector2( + (_rng.NextSingle() - 0.5f) * 60f, + (_rng.NextSingle() - 0.5f) * 60f); + + Items.Add(new SimItem + { + Position = enemy.Position + offset, + Category = category, + Label = PickLootName(category), + }); + } + } + + private LootCategory RollLootCategory() + { + var roll = _rng.NextSingle(); + if (roll < 0.15f) return LootCategory.Currency; + if (roll < 0.50f) return LootCategory.Normal; + if (roll < 0.75f) return LootCategory.Magic; + if (roll < 0.90f) return LootCategory.Rare; + if (roll < 0.95f) return LootCategory.Unique; + return LootCategory.Quest; + } + + private static readonly string[] CurrencyNames = ["Gold", "Scroll of Wisdom", "Exalted Orb", "Chaos Orb", "Transmutation Orb"]; + private static readonly string[] NormalNames = ["Iron Sword", "Leather Cap", "Bone Shield", "Short Bow", "Cloth Robe"]; + private static readonly string[] MagicNames = ["Runic Hatchet", "Serpent Wand", "Chain Gloves", "Wolf Pelt", "Jade Amulet"]; + private static readonly string[] RareNames = ["Dread Edge", "Soul Render", "Viper Strike", "Storm Circlet", "Blood Greaves"]; + private static readonly string[] UniqueNames = ["Headhunter", "Mageblood", "Ashes of the Stars", "Aegis Aurora"]; + private static readonly string[] QuestNames = ["Ancient Tablet", "Relic Shard", "Quest Gem", "Sealed Letter"]; + + private string PickLootName(LootCategory category) + { + var pool = category switch + { + LootCategory.Currency => CurrencyNames, + LootCategory.Normal => NormalNames, + LootCategory.Magic => MagicNames, + LootCategory.Rare => RareNames, + LootCategory.Unique => UniqueNames, + LootCategory.Quest => QuestNames, + _ => NormalNames, + }; + return pool[_rng.Next(pool.Length)]; + } + + private void PickupNearbyItems() + { + for (var i = Items.Count - 1; i >= 0; i--) + { + if (Vector2.Distance(Items[i].Position, Player.Position) < 80f) + Items.RemoveAt(i); + } + } } diff --git a/src/Nexus.Systems/AreaProgressionSystem.cs b/src/Nexus.Systems/AreaProgressionSystem.cs index 5295890..933c365 100644 --- a/src/Nexus.Systems/AreaProgressionSystem.cs +++ b/src/Nexus.Systems/AreaProgressionSystem.cs @@ -182,19 +182,12 @@ public sealed class AreaProgressionSystem : ISystem private void UpdateExploring(GameState state, ActionQueue actions) { - // ── Check 1: Yield for elite combat ── - const float EliteEngagementRange = 800f; - foreach (var m in state.HostileMonsters) + // ── Check 1: Yield for combat — stop exploring to clear nearby monsters ── + if (HasNearbyHostiles(state)) { - if (m.Rarity >= MonsterRarity.Rare && m.DistanceToPlayer < EliteEngagementRange) - { - if (_nav.Mode != NavMode.Idle) - { - Log.Information("Progression: yielding for {Rarity} (dist={Dist:F0})", m.Rarity, m.DistanceToPlayer); - _nav.Stop(); - } - return; - } + if (_nav.Mode != NavMode.Idle) + _nav.Stop(); + return; } // ── Check 2: Quest chest interaction ── @@ -462,6 +455,16 @@ public sealed class AreaProgressionSystem : ISystem private void UpdateNavigatingToTransition(GameState state, ActionQueue actions) { + // Hostiles nearby — abort transition, go fight + if (HasNearbyHostiles(state)) + { + Log.Debug("Progression: hostiles near transition, aborting to fight"); + _targetTransitionEntityId = 0; + _phase = Phase.Exploring; + _nav.Stop(); + return; + } + // Check if the entity is still visible and close enough foreach (var e in state.Entities) { @@ -488,6 +491,15 @@ public sealed class AreaProgressionSystem : ISystem private void UpdateInteracting(GameState state, ActionQueue actions) { + // Hostiles nearby — abort transition, go fight + if (HasNearbyHostiles(state)) + { + Log.Debug("Progression: hostiles near exit, aborting click to fight"); + _targetTransitionEntityId = 0; + _phase = Phase.Exploring; + return; + } + // Project entity to screen and click foreach (var e in state.Entities) { @@ -546,6 +558,23 @@ public sealed class AreaProgressionSystem : ISystem return best.Area.Id; } + private bool HasNearbyHostiles(GameState state) + { + const float EliteEngagementRange = 800f; + var combatRange = _config.CombatEngagementRange; + foreach (var m in state.HostileMonsters) + { + if (!m.IsAlive) continue; + var range = m.Rarity >= MonsterRarity.Rare ? EliteEngagementRange : combatRange; + if (m.DistanceToPlayer >= range) continue; + if (state.Terrain is { } t && + !TerrainQuery.HasLineOfSight(t, state.Player.Position, m.Position, _config.WorldToGrid)) + continue; + return true; + } + return false; + } + private bool HasQuestInThisArea(GameState state) { return state.Quests.Any(q => q.TargetAreas?.Any(a => diff --git a/src/Nexus.Systems/BotTick.cs b/src/Nexus.Systems/BotTick.cs index 53e5921..78f8de2 100644 --- a/src/Nexus.Systems/BotTick.cs +++ b/src/Nexus.Systems/BotTick.cs @@ -83,10 +83,28 @@ public static class BotTick movementBlender.Submit(new MovementIntent(4, steer, 0.4f, "WallSteer")); } + // Exit avoidance: when fighting near area transitions, push away to prevent + // accidentally walking into them (they auto-trigger on proximity) + const float ExitAvoidRange = 300f; + if (shouldEngage && state.Player.HasPosition) + { + foreach (var e in state.Entities) + { + if (e.Category != EntityCategory.AreaTransition) continue; + var away = state.Player.Position - e.Position; + var dist = away.Length(); + if (dist >= ExitAvoidRange || dist < 0.1f) continue; + var strength = 1.0f - (dist / ExitAvoidRange); + movementBlender.Submit(new MovementIntent(6, Vector2.Normalize(away) * strength, 0.9f, "ExitAvoid")); + break; + } + } + movementBlender.Resolve(state.Terrain, state.Player.Position, config.WorldToGrid); var resolved = actionQueue.Resolve(); - if (movementBlender.IsUrgentFlee) + // Block casting during emergency flee or dodge roll — focus on escaping + if (state.ThreatAssessment is { AnyEmergency: true } || state.Player.IsRolling) resolved.RemoveAll(a => a is CastAction); return resolved; diff --git a/src/Nexus.Systems/DodgeSystem.cs b/src/Nexus.Systems/DodgeSystem.cs new file mode 100644 index 0000000..6bc781c --- /dev/null +++ b/src/Nexus.Systems/DodgeSystem.cs @@ -0,0 +1,93 @@ +using System.Numerics; +using Nexus.Core; + +namespace Nexus.Systems; + +/// +/// Detects threatening enemy projectiles and triggers dodge rolls perpendicular to their trajectory. +/// Priority 75 — between Threat (50) and Movement (100). +/// +public class DodgeSystem : ISystem +{ + public int Priority => SystemPriority.Dodge; + public string Name => "Dodge"; + public bool IsEnabled { get; set; } = true; + + public float WorldToGrid { get; set; } = 23f / 250f; + + // Reaction window — only dodge projectiles arriving within this time + private const float ReactionWindow = 0.4f; + + // Only dodge projectiles whose closest approach is within this distance + private const float DodgeThreshold = 80f; + + // Minimum time between dodge decisions (prevents flip-flopping when multiple projectiles arrive) + private float _decisionCooldown; + private const float DecisionCooldownDuration = 0.1f; + + public void Update(GameState state, ActionQueue actions, MovementBlender movement) + { + var dt = state.DeltaTime; + if (_decisionCooldown > 0) + { + _decisionCooldown -= dt; + return; + } + + // Skip if already rolling, on cooldown, or no projectiles + if (state.Player.IsRolling) return; + if (state.Player.RollCooldownRemaining > 0) return; + if (state.EnemyProjectiles.Count == 0) return; + if (!state.Player.HasPosition) return; + + // Find most urgent threatening projectile + ProjectileSnapshot? urgent = null; + foreach (var proj in state.EnemyProjectiles) + { + if (proj.TimeToImpact is not { } tti) continue; // Will miss + if (tti > ReactionWindow) continue; // Too far away to react + if (proj.ClosestApproachDistance > DodgeThreshold) continue; + + if (urgent is null || tti < urgent.TimeToImpact) + urgent = proj; + } + + if (urgent is null) return; + + // Compute dodge direction: perpendicular to projectile trajectory + var projDir = urgent.Direction; + var perp1 = new Vector2(-projDir.Y, projDir.X); // Left perpendicular + var perp2 = new Vector2(projDir.Y, -projDir.X); // Right perpendicular + + // Choose the side that moves us AWAY from the projectile trajectory + var toPlayer = state.Player.Position - urgent.Position; + var dodgeDir = Vector2.Dot(toPlayer, perp1) > 0 ? perp1 : perp2; + + // Terrain validation: check if dodge destination is walkable + var dodgeDist = 100f; // approximate roll distance + var playerPos = state.Player.Position; + var dest1 = playerPos + dodgeDir * dodgeDist; + var gx1 = (int)(dest1.X * WorldToGrid); + var gy1 = (int)(dest1.Y * WorldToGrid); + + if (state.Terrain is { } terrain && !terrain.IsWalkable(gx1, gy1)) + { + // Try the other perpendicular + dodgeDir = -dodgeDir; + var dest2 = playerPos + dodgeDir * dodgeDist; + var gx2 = (int)(dest2.X * WorldToGrid); + var gy2 = (int)(dest2.Y * WorldToGrid); + + if (!terrain.IsWalkable(gx2, gy2)) + return; // Both sides blocked — can't dodge + } + + // Submit dodge action + actions.Submit(new DodgeRollAction(SystemPriority.Dodge, dodgeDir)); + + // Also submit movement bias so other systems know we want to go this way + movement.Submit(new MovementIntent(1, dodgeDir, 0.8f, "Dodge")); + + _decisionCooldown = DecisionCooldownDuration; + } +} diff --git a/src/Nexus.Systems/LootSystem.cs b/src/Nexus.Systems/LootSystem.cs index ffa67f4..0e4ec9f 100644 --- a/src/Nexus.Systems/LootSystem.cs +++ b/src/Nexus.Systems/LootSystem.cs @@ -1,3 +1,4 @@ +using System.Numerics; using Nexus.Core; namespace Nexus.Systems; @@ -6,10 +7,40 @@ public class LootSystem : ISystem { public int Priority => SystemPriority.Loot; public string Name => "Loot"; - public bool IsEnabled { get; set; } = false; + public bool IsEnabled { get; set; } = true; + + private const float LootRange = 400f; + private const float SafeRange = 600f; public void Update(GameState state, ActionQueue actions, MovementBlender movement) { - // STUB: loot detection and pickup logic + if (state.NearbyLoot.Count == 0) return; + + // Don't loot if hostiles are nearby + foreach (var hostile in state.HostileMonsters) + { + if (hostile.IsAlive && hostile.DistanceToPlayer < SafeRange) + return; + } + + // Find nearest loot within range + EntitySnapshot? nearest = null; + var nearestDist = float.MaxValue; + + foreach (var loot in state.NearbyLoot) + { + if (loot.DistanceToPlayer < nearestDist && loot.DistanceToPlayer < LootRange) + { + nearest = loot; + nearestDist = loot.DistanceToPlayer; + } + } + + if (nearest is null) return; + + // Steer toward the item — L3 same as navigation so it replaces explore direction + var dir = nearest.Position - state.Player.Position; + if (dir.LengthSquared() > 1f) + movement.Submit(new MovementIntent(3, Vector2.Normalize(dir), 0f, "Loot")); } } diff --git a/src/Nexus.Systems/MovementSystem.cs b/src/Nexus.Systems/MovementSystem.cs index 3651368..f0048a4 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; } = 80f; + public float MinComfortDistance { get; set; } = 150f; private int _orbitSign = 1; @@ -65,16 +65,19 @@ 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: 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 + // Close < MinComfort: strong push out (don't let enemies stack on us) + // MinComfort..SafeDistance*0.6: moderate push out (keep distance) + // SafeDistance*0.6..0.8: pure orbit (sweet spot) + // SafeDistance*0.8+: gentle pull inward to maintain engagement float radialStrength; if (closestDist < MinComfortDistance) - radialStrength = -0.25f; // too close — gentle push outward - else if (closestDist < SafeDistance * 0.5f) - radialStrength = -0.1f; // somewhat close — slight push outward - else if (closestDist > SafeDistance * 0.7f) - radialStrength = 0.4f; // at edge — pull inward to maintain engagement + radialStrength = -0.6f; // too close — strong push outward + else if (closestDist < SafeDistance * 0.4f) + radialStrength = -0.35f; // close — firm push outward + else if (closestDist < SafeDistance * 0.6f) + radialStrength = -0.15f; // moderate — gentle push outward + else if (closestDist > SafeDistance * 0.8f) + radialStrength = 0.3f; // at edge — pull inward to maintain engagement else radialStrength = 0f; // sweet spot — pure orbit diff --git a/src/Nexus.Systems/SystemFactory.cs b/src/Nexus.Systems/SystemFactory.cs index 27aa401..47dddfe 100644 --- a/src/Nexus.Systems/SystemFactory.cs +++ b/src/Nexus.Systems/SystemFactory.cs @@ -15,6 +15,7 @@ public static class SystemFactory systems.Add(new AreaProgressionSystem(config, nav, AreaGraph.Load())); systems.Add(new ThreatSystem { WorldToGrid = config.WorldToGrid }); + systems.Add(new DodgeSystem { WorldToGrid = config.WorldToGrid }); systems.Add(new MovementSystem { SafeDistance = config.SafeDistance, diff --git a/src/Nexus.Systems/ThreatSystem.cs b/src/Nexus.Systems/ThreatSystem.cs index 744333f..bb8188f 100644 --- a/src/Nexus.Systems/ThreatSystem.cs +++ b/src/Nexus.Systems/ThreatSystem.cs @@ -43,6 +43,10 @@ public class ThreatSystem : ISystem private int _killLoseStreak; private const int KillTargetDebounce = 15; // ~250ms + // Flee commitment — once flee starts, hold for minimum duration to prevent oscillation + private int _fleeCommitTicks; + private const int FleeCommitDuration = 60; // ~1 second at 60Hz + // Logging private ThreatCategory _prevMaxCategory = ThreatCategory.Ignore; private uint? _prevTopThreatId; @@ -181,7 +185,14 @@ public class ThreatSystem : ISystem // Hysteresis on flee transition — require score to drop 15% below threshold to de-escalate var wasFleeing = _prevMaxCategory >= ThreatCategory.Flee; var fleeOffThreshold = wasFleeing ? FleeThreshold * 0.85f : FleeThreshold; - var shouldFlee = _smoothedZoneThreat > fleeOffThreshold || anyEmergency; + var rawShouldFlee = _smoothedZoneThreat > fleeOffThreshold || anyEmergency; + + // Flee commitment: once triggered, hold for minimum duration to prevent oscillation + if (rawShouldFlee) + _fleeCommitTicks = FleeCommitDuration; + else if (_fleeCommitTicks > 0) + _fleeCommitTicks--; + var shouldFlee = _fleeCommitTicks > 0; var areaClear = entries.TrueForAll(e => e.Category < ThreatCategory.Monitor); // Range band counts (backward compat) @@ -274,22 +285,10 @@ public class ThreatSystem : ISystem _prevMaxCategory = zoneCat; } - // ── 6. Submit movement intents ── - if (!shouldFlee) return; - - var isPointBlank = closestDist < PointBlankRange; - - if (anyEmergency || isPointBlank) - { - // Layer 0: near-total override — flee, blocks casting. 0.85 lets wall push still help. - movement.Submit(new MovementIntent(0, safest, 0.85f, "Threat")); - } - else - { - // Layer 1: strong flee scaled by flee weight - var override1 = 0.3f + assessment.FleeWeight * 0.4f; // 0.3–0.7 - movement.Submit(new MovementIntent(1, safest * assessment.FleeWeight, override1, "Threat")); - } + // ── 6. Movement ── + // No raw flee intents — all movement goes through pathfinding (NavigationController) + // which routes around walls. ThreatAssessment.ShouldFlee/SafestDirection are available + // for BotTick to adjust navigation target if needed. } // ── Per-entity scoring ──