work on sim bot

This commit is contained in:
Boki 2026-04-04 16:44:32 -04:00
parent 8ca257bc79
commit f09ee5d106
29 changed files with 889 additions and 60 deletions

View file

@ -10,6 +10,6 @@ Collapsed=0
[Window][Simulator] [Window][Simulator]
Pos=564,96 Pos=564,96
Size=893,571 Size=1023,810
Collapsed=0 Collapsed=0

View file

@ -4,11 +4,26 @@ namespace Nexus.Core;
public static class ActionExecutor 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, 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; 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 // Discrete actions
foreach (var action in resolved) foreach (var action in resolved)
{ {
@ -19,6 +34,7 @@ public static class ActionExecutor
break; break;
case CastAction cast: case CastAction cast:
hasCast = true;
if (cast.TargetScreenPos.HasValue) if (cast.TargetScreenPos.HasValue)
input.SmoothMoveTo((int)cast.TargetScreenPos.Value.X, (int)cast.TargetScreenPos.Value.Y); input.SmoothMoveTo((int)cast.TargetScreenPos.Value.X, (int)cast.TargetScreenPos.Value.Y);
input.KeyPress(cast.SkillScanCode); input.KeyPress(cast.SkillScanCode);
@ -43,6 +59,27 @@ public static class ActionExecutor
case KeyActionType.Up: input.KeyUp(key.ScanCode); break; case KeyActionType.Up: input.KeyUp(key.ScanCode); break;
} }
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);
} }
} }

View file

@ -25,3 +25,5 @@ public record FlaskAction(int Priority, ushort FlaskScanCode) : BotAction(Priori
public record ChatAction(int Priority, string Message) : BotAction(Priority); public record ChatAction(int Priority, string Message) : BotAction(Priority);
public record WaitAction(int Priority, int DurationMs) : BotAction(Priority); public record WaitAction(int Priority, int DurationMs) : BotAction(Priority);
public record DodgeRollAction(int Priority, Vector2 Direction) : BotAction(Priority);

View file

@ -12,6 +12,7 @@ public enum DangerLevel
public static class SystemPriority public static class SystemPriority
{ {
public const int Threat = 50; public const int Threat = 50;
public const int Dodge = 75;
public const int Movement = 100; public const int Movement = 100;
public const int Navigation = 200; public const int Navigation = 200;
public const int Combat = 300; public const int Combat = 300;

View file

@ -27,6 +27,8 @@ public class GameState
/// <summary>In-progress quests from the quest linked list with target areas and paths.</summary> /// <summary>In-progress quests from the quest linked list with target areas and paths.</summary>
public IReadOnlyList<QuestInfo> Quests { get; set; } = []; public IReadOnlyList<QuestInfo> Quests { get; set; } = [];
public IReadOnlyList<ProjectileSnapshot> EnemyProjectiles { get; set; } = [];
// Derived (computed once per tick by GameStateEnricher / ThreatSystem) // Derived (computed once per tick by GameStateEnricher / ThreatSystem)
public ThreatMap Threats { get; set; } = new(); public ThreatMap Threats { get; set; } = new();
public ThreatAssessment ThreatAssessment { get; set; } = new(); public ThreatAssessment ThreatAssessment { get; set; } = new();

110
src/Nexus.Core/HandModel.cs Normal file
View 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,
};
}

View file

@ -1,3 +1,5 @@
using System.Numerics;
namespace Nexus.Core; namespace Nexus.Core;
public interface IInputController public interface IInputController
@ -16,4 +18,10 @@ public interface IInputController
void LeftUp(); void LeftUp();
void RightDown(); void RightDown();
void RightUp(); 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) { }
} }

View file

@ -84,15 +84,19 @@ public sealed class MovementBlender
if (IsStuck) 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); _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) if (_stuckFrames > StuckRecoveryThreshold)
{ {
var angle = StuckRng.NextDouble() * Math.PI * 2; var angle = StuckRng.NextDouble() * Math.PI * 2;
var nudge = new System.Numerics.Vector2((float)Math.Cos(angle), (float)Math.Sin(angle)); var nudge = new Vector2((float)Math.Cos(angle), (float)Math.Sin(angle));
_intents.Add(new MovementIntent(1, nudge, 0.7f, "StuckEscape")); _intents.Add(new MovementIntent(0, nudge, 0.6f, "StuckEscape"));
// Reset counter so we try a new direction periodically // Reset counter so we try a new direction periodically
if (_stuckFrames % 30 == 0) if (_stuckFrames % 30 == 0)
_stuckFrames = StuckRecoveryThreshold + 1; _stuckFrames = StuckRecoveryThreshold + 1;

View file

@ -12,6 +12,11 @@ namespace Nexus.Core;
public sealed class MovementKeyTracker public sealed class MovementKeyTracker
{ {
private bool _wHeld, _aHeld, _sHeld, _dHeld; 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 long _wDownAt, _aDownAt, _sDownAt, _dDownAt;
private int _wMinHold, _aMinHold, _sMinHold, _dMinHold; private int _wMinHold, _aMinHold, _sMinHold, _dMinHold;
private long _wUpAt, _aUpAt, _sUpAt, _dUpAt; private long _wUpAt, _aUpAt, _sUpAt, _dUpAt;

View file

@ -31,4 +31,8 @@ public record PlayerState
// Skill slots (populated by memory when available) // Skill slots (populated by memory when available)
public IReadOnlyList<SkillState> Skills { get; init; } = []; public IReadOnlyList<SkillState> Skills { get; init; } = [];
// Dodge roll state
public bool IsRolling { get; init; }
public float RollCooldownRemaining { get; init; }
} }

View 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; }
}

View file

@ -42,6 +42,14 @@ public sealed class NavigationController
// Grace period after picking a new explore target — don't check stuck immediately // Grace period after picking a new explore target — don't check stuck immediately
private int _stuckGraceTicks; 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 NavMode Mode { get; private set; } = NavMode.Idle;
public Vector2? DesiredDirection { get; private set; } public Vector2? DesiredDirection { get; private set; }
public IReadOnlyList<Vector2>? CurrentPath => _path; public IReadOnlyList<Vector2>? CurrentPath => _path;
@ -139,6 +147,8 @@ public sealed class NavigationController
_exploreBiasPoint = null; _exploreBiasPoint = null;
_exploredGrid = null; _exploredGrid = null;
_pathFailCooldownMs = 0; _pathFailCooldownMs = 0;
_repeatedStuckCount = 0;
_randomWalkTicks = 0;
IsExplorationComplete = false; IsExplorationComplete = false;
} }
@ -195,6 +205,20 @@ public sealed class NavigationController
if (_stuckGraceTicks > 0) if (_stuckGraceTicks > 0)
_stuckGraceTicks--; _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; var isStuck = false;
if (_stuckGraceTicks <= 0 && _positionHistory.Count >= StuckWindowSize && _path is not null) if (_stuckGraceTicks <= 0 && _positionHistory.Count >= StuckWindowSize && _path is not null)
{ {
@ -202,6 +226,27 @@ public sealed class NavigationController
if (Vector2.Distance(oldest, playerPos) < StuckThreshold) if (Vector2.Distance(oldest, playerPos) < StuckThreshold)
{ {
isStuck = true; 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) if (Mode == NavMode.Exploring)
{ {
Log.Information("NavigationController: stuck while exploring, picking new target"); Log.Information("NavigationController: stuck while exploring, picking new target");

View file

@ -28,6 +28,9 @@ public class SimInputController : IInputController
private readonly float[] _mouseTimers = new float[3]; private readonly float[] _mouseTimers = new float[3];
private const float FlashDuration = 0.3f; private const float FlashDuration = 0.3f;
// Dodge roll
private Vector2? _pendingDodgeDirection;
// Smooth mouse interpolation // Smooth mouse interpolation
private Vector2 _mouseMoveStartPos; private Vector2 _mouseMoveStartPos;
private Vector2 _mouseTargetPos; 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) public void KeyPress(ushort scanCode, int holdMs = 50)
{ {
lock (_lock) lock (_lock)
{ {
_keyTimers[scanCode] = FlashDuration; _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 // Queue as skill cast
var target = ScreenToWorld(_mouseScreenPos); var target = ScreenToWorld(_mouseScreenPos);
_world.QueueSkill(scanCode, target); _world.QueueSkill(scanCode, target);

View file

@ -18,6 +18,7 @@ public static class SimStateBuilder
var entities = new List<EntitySnapshot>(); var entities = new List<EntitySnapshot>();
var hostiles = new List<EntitySnapshot>(); var hostiles = new List<EntitySnapshot>();
var nearbyLoot = new List<EntitySnapshot>();
foreach (var enemy in world.Enemies) foreach (var enemy in world.Enemies)
{ {
@ -44,8 +45,65 @@ public static class SimStateBuilder
hostiles.Add(snap); 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 cameraMatrix = BuildCameraMatrix(player.Position);
var projectiles = BuildProjectileSnapshots(world, player.Position);
return new GameState return new GameState
{ {
TickNumber = _tickNumber, TickNumber = _tickNumber,
@ -60,7 +118,8 @@ public static class SimStateBuilder
Terrain = world.Terrain, Terrain = world.Terrain,
Entities = entities, Entities = entities,
HostileMonsters = hostiles, HostileMonsters = hostiles,
NearbyLoot = [], NearbyLoot = nearbyLoot,
EnemyProjectiles = projectiles,
Player = new PlayerState Player = new PlayerState
{ {
CharacterName = "SimPlayer", CharacterName = "SimPlayer",
@ -74,6 +133,8 @@ public static class SimStateBuilder
EsCurrent = player.Es, EsCurrent = player.Es,
EsTotal = player.MaxEs, EsTotal = player.MaxEs,
Skills = BuildSkillStates(), Skills = BuildSkillStates(),
IsRolling = player.IsRolling,
RollCooldownRemaining = player.RollCooldownRemaining,
}, },
}; };
} }
@ -104,6 +165,55 @@ public static class SimStateBuilder
-playerPos.X / halfW, -playerPos.Y / halfH, 0, 1); -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() private static List<SkillState> BuildSkillStates()
{ {
return return

View file

@ -54,6 +54,11 @@ public class SimConfig
public int EnemyGroupMax { get; set; } = 18; public int EnemyGroupMax { get; set; } = 18;
public float EnemyGroupSpread { get; set; } = 120f; 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 // Player skills
public float MeleeRange { get; set; } = 350f; public float MeleeRange { get; set; } = 350f;
public float MeleeConeAngle { get; set; } = 120f; public float MeleeConeAngle { get; set; } = 120f;

View file

@ -72,8 +72,8 @@ foreach (var sys in systems)
// ── Start simulation poller ── // ── Start simulation poller ──
poller.Start(); poller.Start();
// ── Navigate to dungeon end ── // ── Explore the dungeon (not beeline to exit) ──
nav.NavigateTo(world.EndWorldPos); nav.Explore();
// ── Bot logic thread ── // ── Bot logic thread ──
var actionQueue = new ActionQueue(); var actionQueue = new ActionQueue();
@ -96,13 +96,13 @@ var botThread = new Thread(() =>
if (state is not null && !state.IsLoading && !state.IsEscapeOpen) if (state is not null && !state.IsLoading && !state.IsEscapeOpen)
{ {
var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig); var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
ActionExecutor.Execute(resolved, input, moveTracker, movementBlender, state.Player.Position); ActionExecutor.Execute(resolved, input, moveTracker, movementBlender, state.Player.Position, state.CameraMatrix);
// Check if dungeon end reached — regenerate and re-navigate // Check if dungeon end reached — regenerate and explore new dungeon
if (world.ReachedEnd) if (world.ReachedEnd)
{ {
world.RegenerateTerrain(); world.RegenerateTerrain();
nav.NavigateTo(world.EndWorldPos); nav.Explore();
} }
botTickCount++; botTickCount++;

View file

@ -12,8 +12,19 @@ public static class EntityRenderer
var screenPos = vt.WorldToScreen(player.Position); var screenPos = vt.WorldToScreen(player.Position);
var radius = 8f; var radius = 8f;
drawList.AddCircleFilled(screenPos, radius, 0xFF00FF00); // Green if (player.IsRolling)
drawList.AddCircle(screenPos, radius + 1, 0xFF00AA00); {
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; 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, private static void DrawHealthBar(ImDrawListPtr drawList, Vector2 pos, float width, float height,
int current, int max, uint color) int current, int max, uint color)
{ {

View file

@ -24,6 +24,7 @@ public static class InputOverlayRenderer
private const uint ScrollBg = 0xFF333333; private const uint ScrollBg = 0xFF333333;
private const uint CursorDot = 0xFF00DDFF; // Cyan dot for cursor position private const uint CursorDot = 0xFF00DDFF; // Cyan dot for cursor position
private const uint CrosshairColor = 0x44FFFFFF; // Dim crosshair private const uint CrosshairColor = 0x44FFFFFF; // Dim crosshair
private const float SpaceBarHeight = 20f;
// Keyboard rows: (label, scanCode, column offset) // Keyboard rows: (label, scanCode, column offset)
private static readonly (string L, ushort S, float C)[] Row0 = private static readonly (string L, ushort S, float C)[] Row0 =
@ -44,7 +45,7 @@ public static class InputOverlayRenderer
{ {
var padSize = 80f; var padSize = 80f;
var mouseH = 64f; var mouseH = 64f;
var kbH = 3 * Stride; var kbH = 3 * Stride + SpaceBarHeight + Gap;
var totalH = kbH + 6 + mouseH + 6 + padSize; var totalH = kbH + 6 + mouseH + 6 + padSize;
var origin = canvasOrigin + new Vector2(15, canvasSize.Y - totalH - 15); 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, Row0, 0, input);
DrawKeyRow(drawList, origin, Row1, 1, input); DrawKeyRow(drawList, origin, Row1, 1, input);
DrawKeyRow(drawList, origin, Row2, 2, input); DrawKeyRow(drawList, origin, Row2, 2, input);
DrawSpaceBar(drawList, origin + new Vector2(0, 3 * Stride), input);
// Mouse to the right of keyboard // Mouse to the right of keyboard
var kbW = 4.25f * Stride + KeySize; 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) private static void DrawMouse(ImDrawListPtr drawList, InputSnapshot input, Vector2 o)
{ {
const float w = 44, h = 64, hw = w / 2, bh = 26; const float w = 44, h = 64, hw = w / 2, bh = 26;

View file

@ -75,17 +75,24 @@ public class SimRenderer
var effects = _world.ActiveEffects.ToArray(); var effects = _world.ActiveEffects.ToArray();
var projectiles = _world.Projectiles.ToArray(); var projectiles = _world.Projectiles.ToArray();
var enemies = _world.Enemies.ToArray(); var enemies = _world.Enemies.ToArray();
var items = _world.Items.ToArray();
EffectRenderer.DrawEffects(drawList, effects, vt); EffectRenderer.DrawEffects(drawList, effects, vt);
EffectRenderer.DrawProjectiles(drawList, projectiles, 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); EntityRenderer.DrawEnemies(drawList, enemies, vt, canvasOrigin, canvasOrigin + canvasSize);
// 5. Player // 6. Player
EntityRenderer.DrawPlayer(drawList, _world.Player, vt); 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); DrawMockCursor(drawList, vt);
drawList.PopClipRect(); 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) private void DrawMockCursor(ImDrawListPtr drawList, ViewTransform vt)
{ {
// Convert the bot's mouse screen position to world, then to our viewport // Convert the bot's mouse screen position to world, then to our viewport

View 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; } = "";
}

View file

@ -17,6 +17,13 @@ public class SimPlayer
public float EsRegen { get; set; } public float EsRegen { get; set; }
public float EsRechargeDelay { 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 // Accumulate fractional regen
private float _healthRegenAccum; private float _healthRegenAccum;
private float _manaRegenAccum; private float _manaRegenAccum;
@ -41,8 +48,21 @@ public class SimPlayer
_timeSinceLastDamage = esRechargeDelay; // Start with ES recharging _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) public void Update(float dt)
{ {
// Tick dodge cooldown
if (RollCooldownRemaining > 0)
RollCooldownRemaining = MathF.Max(0, RollCooldownRemaining - dt);
_timeSinceLastDamage += dt; _timeSinceLastDamage += dt;
// Health regen (always active) // Health regen (always active)

View file

@ -14,6 +14,7 @@ public class SimWorld
public List<SimEnemy> Enemies { get; } = []; public List<SimEnemy> Enemies { get; } = [];
public List<SimProjectile> Projectiles { get; } = []; public List<SimProjectile> Projectiles { get; } = [];
public List<SimSkillEffect> ActiveEffects { get; } = []; public List<SimSkillEffect> ActiveEffects { get; } = [];
public List<SimItem> Items { get; } = [];
public WalkabilitySnapshot Terrain { get; private set; } public WalkabilitySnapshot Terrain { get; private set; }
public long TickNumber { get; private set; } public long TickNumber { get; private set; }
public Vector2 StartWorldPos { get; private set; } public Vector2 StartWorldPos { get; private set; }
@ -30,6 +31,10 @@ public class SimWorld
public Vector2 MouseWorldPos { get; set; } public Vector2 MouseWorldPos { get; set; }
private readonly Queue<(ushort scanCode, Vector2 targetWorldPos)> _skillQueue = new(); private readonly Queue<(ushort scanCode, Vector2 targetWorldPos)> _skillQueue = new();
// Dodge roll
private Vector2? _pendingDodgeDirection;
private Vector2 _lastFacingDirection = Vector2.UnitX;
public SimWorld(SimConfig config) public SimWorld(SimConfig config)
{ {
_config = config; _config = config;
@ -78,6 +83,7 @@ public class SimWorld
Enemies.Clear(); Enemies.Clear();
Projectiles.Clear(); Projectiles.Clear();
ActiveEffects.Clear(); ActiveEffects.Clear();
Items.Clear();
_respawnQueue.Clear(); _respawnQueue.Clear();
SpawnEnemiesInRooms(dungeon.Rooms); SpawnEnemiesInRooms(dungeon.Rooms);
Log.Information("Dungeon regenerated (seed={Seed}), {Rooms} rooms", _dungeonSeed, dungeon.Rooms.Count); Log.Information("Dungeon regenerated (seed={Seed}), {Rooms} rooms", _dungeonSeed, dungeon.Rooms.Count);
@ -88,6 +94,11 @@ public class SimWorld
_skillQueue.Enqueue((scanCode, targetWorldPos)); _skillQueue.Enqueue((scanCode, targetWorldPos));
} }
public void QueueDodgeRoll(Vector2 direction)
{
_pendingDodgeDirection = direction;
}
public void Tick(float dt) public void Tick(float dt)
{ {
if (_config.IsPaused) return; if (_config.IsPaused) return;
@ -102,6 +113,17 @@ public class SimWorld
Log.Information("Reached dungeon end!"); 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 // 1. Move player
MovePlayer(dt); MovePlayer(dt);
@ -120,7 +142,10 @@ public class SimWorld
// 6. Process respawn queue // 6. Process respawn queue
UpdateRespawns(dt); UpdateRespawns(dt);
// 7. Player regen // 7. Pickup items near player
PickupNearbyItems();
// 8. Player regen
Player.Update(dt); Player.Update(dt);
} }
@ -134,9 +159,17 @@ public class SimWorld
private void MovePlayer(float dt) private void MovePlayer(float dt)
{ {
// Dodge roll overrides normal movement
if (Player.IsRolling)
{
MovePlayerDodgeRoll(dt);
return;
}
if (MoveDirection.LengthSquared() < 0.001f) return; if (MoveDirection.LengthSquared() < 0.001f) return;
var dir = Vector2.Normalize(MoveDirection); var dir = Vector2.Normalize(MoveDirection);
_lastFacingDirection = dir; // Track for stationary dodge
var step = Player.MoveSpeed * dt; var step = Player.MoveSpeed * dt;
// Try full direction // 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) private bool TryMove(Vector2 dir, float step)
{ {
var newPos = Player.Position + dir * step; var newPos = Player.Position + dir * step;
@ -246,7 +319,10 @@ public class SimWorld
enemy.TakeDamage(_config.SkillBaseDamage); enemy.TakeDamage(_config.SkillBaseDamage);
if (!enemy.IsAlive) if (!enemy.IsAlive)
{
Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id); 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); enemy.TakeDamage(_config.SkillBaseDamage);
if (!enemy.IsAlive) if (!enemy.IsAlive)
{
Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id); 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); enemy.TakeDamage(proj.Damage);
if (!enemy.IsAlive) if (!enemy.IsAlive)
{
Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id); Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id);
SpawnLoot(enemy);
}
proj.IsExpired = true; proj.IsExpired = true;
break; break;
} }
@ -602,4 +684,77 @@ public class SimWorld
if (roll < _config.UniqueChance + _config.RareChance + _config.MagicChance) return MonsterRarity.Magic; if (roll < _config.UniqueChance + _config.RareChance + _config.MagicChance) return MonsterRarity.Magic;
return MonsterRarity.White; 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);
}
}
} }

View file

@ -182,19 +182,12 @@ public sealed class AreaProgressionSystem : ISystem
private void UpdateExploring(GameState state, ActionQueue actions) private void UpdateExploring(GameState state, ActionQueue actions)
{ {
// ── Check 1: Yield for elite combat ── // ── Check 1: Yield for combat — stop exploring to clear nearby monsters ──
const float EliteEngagementRange = 800f; if (HasNearbyHostiles(state))
foreach (var m in state.HostileMonsters)
{ {
if (m.Rarity >= MonsterRarity.Rare && m.DistanceToPlayer < EliteEngagementRange) if (_nav.Mode != NavMode.Idle)
{ _nav.Stop();
if (_nav.Mode != NavMode.Idle) return;
{
Log.Information("Progression: yielding for {Rarity} (dist={Dist:F0})", m.Rarity, m.DistanceToPlayer);
_nav.Stop();
}
return;
}
} }
// ── Check 2: Quest chest interaction ── // ── Check 2: Quest chest interaction ──
@ -462,6 +455,16 @@ public sealed class AreaProgressionSystem : ISystem
private void UpdateNavigatingToTransition(GameState state, ActionQueue actions) 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 // Check if the entity is still visible and close enough
foreach (var e in state.Entities) foreach (var e in state.Entities)
{ {
@ -488,6 +491,15 @@ public sealed class AreaProgressionSystem : ISystem
private void UpdateInteracting(GameState state, ActionQueue actions) 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 // Project entity to screen and click
foreach (var e in state.Entities) foreach (var e in state.Entities)
{ {
@ -546,6 +558,23 @@ public sealed class AreaProgressionSystem : ISystem
return best.Area.Id; 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) private bool HasQuestInThisArea(GameState state)
{ {
return state.Quests.Any(q => q.TargetAreas?.Any(a => return state.Quests.Any(q => q.TargetAreas?.Any(a =>

View file

@ -83,10 +83,28 @@ public static class BotTick
movementBlender.Submit(new MovementIntent(4, steer, 0.4f, "WallSteer")); 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); movementBlender.Resolve(state.Terrain, state.Player.Position, config.WorldToGrid);
var resolved = actionQueue.Resolve(); 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); resolved.RemoveAll(a => a is CastAction);
return resolved; return resolved;

View 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;
}
}

View file

@ -1,3 +1,4 @@
using System.Numerics;
using Nexus.Core; using Nexus.Core;
namespace Nexus.Systems; namespace Nexus.Systems;
@ -6,10 +7,40 @@ public class LootSystem : ISystem
{ {
public int Priority => SystemPriority.Loot; public int Priority => SystemPriority.Loot;
public string Name => "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) 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"));
} }
} }

View file

@ -22,7 +22,7 @@ public class MovementSystem : ISystem
public float WorldToGrid { get; set; } = 23f / 250f; public float WorldToGrid { get; set; } = 23f / 250f;
/// <summary>Minimum distance before radial push kicks in hard.</summary> /// <summary>Minimum distance before radial push kicks in hard.</summary>
public float MinComfortDistance { get; set; } = 80f; public float MinComfortDistance { get; set; } = 150f;
private int _orbitSign = 1; private int _orbitSign = 1;
@ -65,16 +65,19 @@ public class MovementSystem : ISystem
var tangent = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign; var tangent = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign;
// Radial component — push away from centroid, strength based on proximity // Radial component — push away from centroid, strength based on proximity
// Close < MinComfort: gentle push out (avoid stacking on top of enemies) // Close < MinComfort: strong push out (don't let enemies stack on us)
// MinComfort..SafeDistance*0.5: slight push out // MinComfort..SafeDistance*0.6: moderate push out (keep distance)
// SafeDistance*0.7+: pull inward to maintain engagement instead of drifting away // SafeDistance*0.6..0.8: pure orbit (sweet spot)
// SafeDistance*0.8+: gentle pull inward to maintain engagement
float radialStrength; float radialStrength;
if (closestDist < MinComfortDistance) if (closestDist < MinComfortDistance)
radialStrength = -0.25f; // too close — gentle push outward radialStrength = -0.6f; // too close — strong push outward
else if (closestDist < SafeDistance * 0.5f) else if (closestDist < SafeDistance * 0.4f)
radialStrength = -0.1f; // somewhat close — slight push outward radialStrength = -0.35f; // close — firm push outward
else if (closestDist > SafeDistance * 0.7f) else if (closestDist < SafeDistance * 0.6f)
radialStrength = 0.4f; // at edge — pull inward to maintain engagement radialStrength = -0.15f; // moderate — gentle push outward
else if (closestDist > SafeDistance * 0.8f)
radialStrength = 0.3f; // at edge — pull inward to maintain engagement
else else
radialStrength = 0f; // sweet spot — pure orbit radialStrength = 0f; // sweet spot — pure orbit

View file

@ -15,6 +15,7 @@ public static class SystemFactory
systems.Add(new AreaProgressionSystem(config, nav, AreaGraph.Load())); systems.Add(new AreaProgressionSystem(config, nav, AreaGraph.Load()));
systems.Add(new ThreatSystem { WorldToGrid = config.WorldToGrid }); systems.Add(new ThreatSystem { WorldToGrid = config.WorldToGrid });
systems.Add(new DodgeSystem { WorldToGrid = config.WorldToGrid });
systems.Add(new MovementSystem systems.Add(new MovementSystem
{ {
SafeDistance = config.SafeDistance, SafeDistance = config.SafeDistance,

View file

@ -43,6 +43,10 @@ public class ThreatSystem : ISystem
private int _killLoseStreak; private int _killLoseStreak;
private const int KillTargetDebounce = 15; // ~250ms 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 // Logging
private ThreatCategory _prevMaxCategory = ThreatCategory.Ignore; private ThreatCategory _prevMaxCategory = ThreatCategory.Ignore;
private uint? _prevTopThreatId; 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 // Hysteresis on flee transition — require score to drop 15% below threshold to de-escalate
var wasFleeing = _prevMaxCategory >= ThreatCategory.Flee; var wasFleeing = _prevMaxCategory >= ThreatCategory.Flee;
var fleeOffThreshold = wasFleeing ? FleeThreshold * 0.85f : FleeThreshold; 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); var areaClear = entries.TrueForAll(e => e.Category < ThreatCategory.Monitor);
// Range band counts (backward compat) // Range band counts (backward compat)
@ -274,22 +285,10 @@ public class ThreatSystem : ISystem
_prevMaxCategory = zoneCat; _prevMaxCategory = zoneCat;
} }
// ── 6. Submit movement intents ── // ── 6. Movement ──
if (!shouldFlee) return; // No raw flee intents — all movement goes through pathfinding (NavigationController)
// which routes around walls. ThreatAssessment.ShouldFlee/SafestDirection are available
var isPointBlank = closestDist < PointBlankRange; // for BotTick to adjust navigation target if needed.
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.30.7
movement.Submit(new MovementIntent(1, safest * assessment.FleeWeight, override1, "Threat"));
}
} }
// ── Per-entity scoring ── // ── Per-entity scoring ──