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]
|
[Window][Simulator]
|
||||||
Pos=564,96
|
Pos=564,96
|
||||||
Size=893,571
|
Size=1023,810
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
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;
|
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) { }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
// 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");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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 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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
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;
|
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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.3–0.7
|
|
||||||
movement.Submit(new MovementIntent(1, safest * assessment.FleeWeight, override1, "Threat"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-entity scoring ──
|
// ── Per-entity scoring ──
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue