work on sim bot
This commit is contained in:
parent
8ca257bc79
commit
f09ee5d106
29 changed files with 889 additions and 60 deletions
|
|
@ -10,6 +10,6 @@ Collapsed=0
|
|||
|
||||
[Window][Simulator]
|
||||
Pos=564,96
|
||||
Size=893,571
|
||||
Size=1023,810
|
||||
Collapsed=0
|
||||
|
||||
|
|
|
|||
|
|
@ -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<BotAction> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ public class GameState
|
|||
/// <summary>In-progress quests from the quest linked list with target areas and paths.</summary>
|
||||
public IReadOnlyList<QuestInfo> Quests { get; set; } = [];
|
||||
|
||||
public IReadOnlyList<ProjectileSnapshot> EnemyProjectiles { get; set; } = [];
|
||||
|
||||
// Derived (computed once per tick by GameStateEnricher / ThreatSystem)
|
||||
public ThreatMap Threats { get; set; } = new();
|
||||
public ThreatAssessment ThreatAssessment { get; set; } = new();
|
||||
|
|
|
|||
110
src/Nexus.Core/HandModel.cs
Normal file
110
src/Nexus.Core/HandModel.cs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
namespace Nexus.Core;
|
||||
|
||||
public enum Finger { Pinky, Ring, Middle, Index, Thumb }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class HandModel
|
||||
{
|
||||
private static readonly Dictionary<ushort, Finger> 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<BotAction> Filter(List<BotAction> resolved,
|
||||
bool wHeld, bool aHeld, bool sHeld, bool dHeld)
|
||||
{
|
||||
// Build occupied set from currently held WASD keys
|
||||
var occupied = new HashSet<Finger>();
|
||||
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<BotAction>(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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the direction for the next dodge roll. Called before KeyPress(0x21).
|
||||
/// Default no-op for real input controllers (direction comes from game state).
|
||||
/// </summary>
|
||||
void SetDodgeDirection(Vector2 direction) { }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -31,4 +31,8 @@ public record PlayerState
|
|||
|
||||
// Skill slots (populated by memory when available)
|
||||
public IReadOnlyList<SkillState> Skills { get; init; } = [];
|
||||
|
||||
// Dodge roll state
|
||||
public bool IsRolling { get; init; }
|
||||
public float RollCooldownRemaining { get; init; }
|
||||
}
|
||||
|
|
|
|||
16
src/Nexus.Core/ProjectileSnapshot.cs
Normal file
16
src/Nexus.Core/ProjectileSnapshot.cs
Normal file
|
|
@ -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; }
|
||||
/// <summary>Seconds until impact. Null if projectile will miss.</summary>
|
||||
public float? TimeToImpact { get; init; }
|
||||
/// <summary>Closest distance the projectile's trajectory passes to the player center.</summary>
|
||||
public float ClosestApproachDistance { get; init; }
|
||||
}
|
||||
|
|
@ -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<Vector2>? 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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ public static class SimStateBuilder
|
|||
|
||||
var entities = new List<EntitySnapshot>();
|
||||
var hostiles = new List<EntitySnapshot>();
|
||||
var nearbyLoot = new List<EntitySnapshot>();
|
||||
|
||||
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<ProjectileSnapshot> BuildProjectileSnapshots(SimWorld world, Vector2 playerPos)
|
||||
{
|
||||
var snapshots = new List<ProjectileSnapshot>();
|
||||
|
||||
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<SkillState> BuildSkillStates()
|
||||
{
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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<SimItem> 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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws an axis-aligned rectangle centered on the player showing the approximate
|
||||
/// field of view of a 2560x1440 monitor. Scales with sim zoom.
|
||||
/// </summary>
|
||||
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
|
||||
|
|
|
|||
15
src/Nexus.Simulator/World/SimItem.cs
Normal file
15
src/Nexus.Simulator/World/SimItem.cs
Normal file
|
|
@ -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; } = "";
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ public class SimWorld
|
|||
public List<SimEnemy> Enemies { get; } = [];
|
||||
public List<SimProjectile> Projectiles { get; } = [];
|
||||
public List<SimSkillEffect> ActiveEffects { get; } = [];
|
||||
public List<SimItem> 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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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]).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
93
src/Nexus.Systems/DodgeSystem.cs
Normal file
93
src/Nexus.Systems/DodgeSystem.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System.Numerics;
|
||||
using Nexus.Core;
|
||||
|
||||
namespace Nexus.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// Detects threatening enemy projectiles and triggers dodge rolls perpendicular to their trajectory.
|
||||
/// Priority 75 — between Threat (50) and Movement (100).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; } = 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue