threat better
This commit is contained in:
parent
05bbcb244f
commit
703cfbfdee
12 changed files with 581 additions and 228 deletions
|
|
@ -4,12 +4,12 @@ Size=400,400
|
|||
Collapsed=0
|
||||
|
||||
[Window][Simulator Controls]
|
||||
Pos=60,60
|
||||
Size=219,425
|
||||
Pos=29,51
|
||||
Size=432,649
|
||||
Collapsed=0
|
||||
|
||||
[Window][Simulator]
|
||||
Pos=341,232
|
||||
Pos=499,177
|
||||
Size=1200,681
|
||||
Collapsed=0
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ public class GameState
|
|||
/// <summary>In-progress quests from the quest linked list with target areas and paths.</summary>
|
||||
public IReadOnlyList<QuestInfo> Quests { get; set; } = [];
|
||||
|
||||
// Derived (computed once per tick by GameStateEnricher)
|
||||
// Derived (computed once per tick by GameStateEnricher / ThreatSystem)
|
||||
public ThreatMap Threats { get; set; } = new();
|
||||
public ThreatAssessment ThreatAssessment { get; set; } = new();
|
||||
public IReadOnlyList<EntitySnapshot> NearestEnemies { get; set; } = [];
|
||||
public IReadOnlyList<GroundEffect> GroundEffects { get; set; } = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,15 +28,14 @@ public sealed class MovementBlender
|
|||
// Snap decision based on INTENT change (pre-terrain), not terrain output — prevents
|
||||
// terrain probe noise from bypassing the EMA via the snap threshold.
|
||||
private Vector2? _smoothedDirection;
|
||||
private Vector2? _lastIntentDir; // pre-terrain direction from previous frame
|
||||
private const float SmoothingAlpha = 0.12f; // 12% new, 88% previous
|
||||
private const float SmoothingAlpha = 0.20f; // 20% new, 80% previous
|
||||
|
||||
// Terrain validation cache — prevents re-probing within the same grid cell,
|
||||
// Terrain validation cache — prevents re-probing within a small radius,
|
||||
// breaking the position↔direction feedback loop that causes zigzag oscillation
|
||||
private Vector2 _cachedTerrainInputDir;
|
||||
private Vector2 _cachedTerrainResult;
|
||||
private int _cachedTerrainGridX = int.MinValue;
|
||||
private int _cachedTerrainGridY = int.MinValue;
|
||||
private Vector2 _cachedTerrainPos;
|
||||
private const float TerrainCacheRadius = 20f; // don't re-probe within 20 world units
|
||||
|
||||
public Vector2? Direction { get; private set; }
|
||||
public Vector2? RawDirection { get; private set; }
|
||||
|
|
@ -130,19 +129,16 @@ public sealed class MovementBlender
|
|||
|
||||
// Normalize the blended result
|
||||
var rawDir = Vector2.Normalize(result);
|
||||
var intentDir = rawDir; // save pre-terrain direction for snap decision
|
||||
|
||||
// Terrain validation with grid-cell caching.
|
||||
// Re-probe only when the raw direction changes (>~14°) or the player enters a new grid cell.
|
||||
// This prevents the feedback loop: direction jitter → zigzag movement → crosses cell boundary → more jitter.
|
||||
if (terrain is not null)
|
||||
{
|
||||
var gx = (int)(playerPos.X * worldToGrid);
|
||||
var gy = (int)(playerPos.Y * worldToGrid);
|
||||
var dirSimilar = Vector2.Dot(rawDir, _cachedTerrainInputDir) > 0.97f;
|
||||
var sameCell = gx == _cachedTerrainGridX && gy == _cachedTerrainGridY;
|
||||
var nearbyPos = Vector2.DistanceSquared(playerPos, _cachedTerrainPos) < TerrainCacheRadius * TerrainCacheRadius;
|
||||
|
||||
if (dirSimilar && sameCell)
|
||||
if (dirSimilar && nearbyPos)
|
||||
{
|
||||
rawDir = _cachedTerrainResult;
|
||||
}
|
||||
|
|
@ -152,28 +148,23 @@ public sealed class MovementBlender
|
|||
rawDir = TerrainQuery.FindWalkableDirection(terrain, playerPos, rawDir, worldToGrid);
|
||||
_cachedTerrainInputDir = preTerrainDir;
|
||||
_cachedTerrainResult = rawDir;
|
||||
_cachedTerrainGridX = gx;
|
||||
_cachedTerrainGridY = gy;
|
||||
_cachedTerrainPos = playerPos;
|
||||
}
|
||||
}
|
||||
|
||||
RawDirection = rawDir;
|
||||
|
||||
// EMA smoothing. Snap decision based on whether the INTENT (pre-terrain) changed,
|
||||
// not the terrain output. This prevents terrain probe noise (which can produce 90°+ swings)
|
||||
// from bypassing the EMA via the snap threshold.
|
||||
// EMA smoothing. Only snap (bypass smoothing) on urgent flee (L0),
|
||||
// which needs instant response. All other direction changes (orbit flips,
|
||||
// terrain jitter, waypoint changes) get smoothed to prevent oscillation.
|
||||
if (_smoothedDirection.HasValue)
|
||||
{
|
||||
var intentChanged = _lastIntentDir.HasValue &&
|
||||
Vector2.Dot(_lastIntentDir.Value, intentDir) < 0f;
|
||||
|
||||
if (intentChanged)
|
||||
if (IsUrgentFlee)
|
||||
{
|
||||
// Genuine intent reversal (flee, new waypoint) — snap immediately
|
||||
// Emergency flee — snap immediately, no smoothing
|
||||
}
|
||||
else
|
||||
{
|
||||
// Intent is stable — all direction change is terrain noise, always smooth
|
||||
var smoothed = Vector2.Lerp(_smoothedDirection.Value, rawDir, SmoothingAlpha);
|
||||
if (smoothed.LengthSquared() > 0.0001f)
|
||||
rawDir = Vector2.Normalize(smoothed);
|
||||
|
|
@ -181,7 +172,6 @@ public sealed class MovementBlender
|
|||
}
|
||||
|
||||
_smoothedDirection = rawDir;
|
||||
_lastIntentDir = intentDir;
|
||||
Direction = rawDir;
|
||||
}
|
||||
|
||||
|
|
@ -199,9 +189,7 @@ public sealed class MovementBlender
|
|||
_stuckFrames = 0;
|
||||
_lastResolvePos = Vector2.Zero;
|
||||
_smoothedDirection = null;
|
||||
_lastIntentDir = null;
|
||||
_cachedTerrainGridX = int.MinValue;
|
||||
_cachedTerrainGridY = int.MinValue;
|
||||
_cachedTerrainPos = new Vector2(float.MinValue, float.MinValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ public sealed class MovementKeyTracker
|
|||
private bool _wHeld, _aHeld, _sHeld, _dHeld;
|
||||
private long _wDownAt, _aDownAt, _sDownAt, _dDownAt;
|
||||
private int _wMinHold, _aMinHold, _sMinHold, _dMinHold;
|
||||
private long _wUpAt, _aUpAt, _sUpAt, _dUpAt;
|
||||
private int _wRepress, _aRepress, _sRepress, _dRepress;
|
||||
private Vector2? _lastPlayerPos;
|
||||
|
||||
private static readonly Random Rng = new();
|
||||
|
|
@ -55,10 +57,10 @@ public sealed class MovementKeyTracker
|
|||
}
|
||||
|
||||
var now = Environment.TickCount64;
|
||||
SetKey(input, ScanCodes.W, ref _wHeld, ref _wDownAt, ref _wMinHold, wantW, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.A, ref _aHeld, ref _aDownAt, ref _aMinHold, wantA, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.S, ref _sHeld, ref _sDownAt, ref _sMinHold, wantS, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.D, ref _dHeld, ref _dDownAt, ref _dMinHold, wantD, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.W, ref _wHeld, ref _wDownAt, ref _wMinHold, ref _wUpAt, ref _wRepress, wantW, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.A, ref _aHeld, ref _aDownAt, ref _aMinHold, ref _aUpAt, ref _aRepress, wantA, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.S, ref _sHeld, ref _sDownAt, ref _sMinHold, ref _sUpAt, ref _sRepress, wantS, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.D, ref _dHeld, ref _dDownAt, ref _dMinHold, ref _dUpAt, ref _dRepress, wantD, now, _lastPlayerPos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -78,10 +80,14 @@ public sealed class MovementKeyTracker
|
|||
};
|
||||
|
||||
private static void SetKey(IInputController input, ushort scanCode,
|
||||
ref bool held, ref long downAt, ref int minHold, bool want, long now, Vector2? pos)
|
||||
ref bool held, ref long downAt, ref int minHold,
|
||||
ref long upAt, ref int repressDelay, bool want, long now, Vector2? pos)
|
||||
{
|
||||
if (want && !held)
|
||||
{
|
||||
// Enforce re-press cooldown after release
|
||||
if (now - upAt < repressDelay) return;
|
||||
|
||||
input.KeyDown(scanCode);
|
||||
held = true;
|
||||
downAt = now;
|
||||
|
|
@ -96,8 +102,11 @@ public sealed class MovementKeyTracker
|
|||
{
|
||||
var elapsed = now - downAt;
|
||||
if (elapsed < minHold) return; // enforce minimum hold
|
||||
|
||||
input.KeyUp(scanCode);
|
||||
held = false;
|
||||
upAt = now;
|
||||
repressDelay = RepressMs();
|
||||
if (pos.HasValue)
|
||||
Log.Information("[WASD] {Key} UP (held={Elapsed}ms, min={MinHold}ms) pos=({X:F0},{Y:F0})",
|
||||
KeyName(scanCode), elapsed, minHold, pos.Value.X, pos.Value.Y);
|
||||
|
|
@ -119,4 +128,18 @@ public sealed class MovementKeyTracker
|
|||
var g = u * Math.Sqrt(-2.0 * Math.Log(s) / s);
|
||||
return Math.Clamp((int)Math.Round(55.0 + g * 6.0), 44, 76);
|
||||
}
|
||||
|
||||
/// <summary>Gaussian re-press cooldown peaked at 40ms, range [25, 65].</summary>
|
||||
private static int RepressMs()
|
||||
{
|
||||
double u, v, s;
|
||||
do
|
||||
{
|
||||
u = Rng.NextDouble() * 2.0 - 1.0;
|
||||
v = Rng.NextDouble() * 2.0 - 1.0;
|
||||
s = u * u + v * v;
|
||||
} while (s >= 1.0 || s == 0.0);
|
||||
var g = u * Math.Sqrt(-2.0 * Math.Log(s) / s);
|
||||
return Math.Clamp((int)Math.Round(40.0 + g * 8.0), 25, 65);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
src/Nexus.Core/ThreatAssessment.cs
Normal file
51
src/Nexus.Core/ThreatAssessment.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace Nexus.Core;
|
||||
|
||||
public enum ThreatCategory
|
||||
{
|
||||
Ignore, // score ≤ 2 — not worth reacting to
|
||||
Monitor, // score 2–6 — track but don't change behavior
|
||||
Engage, // score 6–15 — fight, stay mobile
|
||||
Flee, // score 15–25 — kite aggressively
|
||||
Emergency, // score > 25 OR player HP critical — run, pop flasks
|
||||
}
|
||||
|
||||
public class ThreatEntry
|
||||
{
|
||||
public uint EntityId { get; init; }
|
||||
public Vector2 Position { get; set; }
|
||||
public float DistanceToPlayer { get; set; }
|
||||
public float ThreatScore { get; set; }
|
||||
public float PerceivedDanger { get; set; } // normalized 0..1
|
||||
public ThreatCategory Category { get; set; }
|
||||
public bool HasLineOfSight { get; set; }
|
||||
public MonsterRarity Rarity { get; init; }
|
||||
public float HpPercent { get; set; }
|
||||
public bool IsAlive { get; set; }
|
||||
}
|
||||
|
||||
public class ThreatAssessment
|
||||
{
|
||||
public List<ThreatEntry> Entries { get; set; } = [];
|
||||
|
||||
// Precomputed aggregates — consumed by steering, combat, state machine
|
||||
public float ZoneThreatLevel { get; set; } // sum of all scores
|
||||
public ThreatEntry? PrimaryTarget { get; set; } // best kill target
|
||||
public ThreatEntry? MostDangerous { get; set; } // highest threat score
|
||||
public Vector2 ThreatCentroid { get; set; } // score-weighted center
|
||||
public Vector2 SafestDirection { get; set; } // away from centroid
|
||||
public bool AnyEmergency { get; set; }
|
||||
public bool ShouldFlee { get; set; } // zone threat > flee threshold
|
||||
public bool AreaClear { get; set; } // no Monitor+ threats remain
|
||||
public float ClosestDistance { get; set; }
|
||||
|
||||
/// <summary>Continuous 0..1 flee weight for steering blend.</summary>
|
||||
public float FleeWeight { get; set; }
|
||||
|
||||
// Convenience — backward compatibility
|
||||
public int CloseRange { get; set; } // < 300
|
||||
public int MidRange { get; set; } // 300–600
|
||||
public int FarRange { get; set; } // 600–1200
|
||||
public bool HasRareOrUnique { get; set; }
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ public class ThreatMap
|
|||
public int CloseRange { get; init; } // < 300 units
|
||||
public int MidRange { get; init; } // 300–600
|
||||
public int FarRange { get; init; } // 600–1200
|
||||
public float ClosestDistance { get; init; } = float.MaxValue;
|
||||
public float ClosestDistance { get; init; }
|
||||
public Vector2 ThreatCentroid { get; init; }
|
||||
public bool HasRareOrUnique { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,14 @@ using Nexus.Core;
|
|||
namespace Nexus.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Computes all derived fields on GameState once per tick.
|
||||
/// Static methods, no allocations beyond the sorted list.
|
||||
/// Computes derived fields on GameState once per tick.
|
||||
/// Threat scoring is now handled by ThreatSystem (runs as ISystem).
|
||||
/// </summary>
|
||||
public static class GameStateEnricher
|
||||
{
|
||||
public static void Enrich(GameState state)
|
||||
{
|
||||
state.NearestEnemies = ComputeNearestEnemies(state.HostileMonsters);
|
||||
state.Threats = ComputeThreatMap(state.HostileMonsters);
|
||||
state.Danger = ComputeDangerLevel(state);
|
||||
state.GroundEffects = []; // stub until memory reads ground effects
|
||||
}
|
||||
|
||||
|
|
@ -25,143 +23,4 @@ public static class GameStateEnricher
|
|||
sorted.Sort((a, b) => a.DistanceToPlayer.CompareTo(b.DistanceToPlayer));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private static ThreatMap ComputeThreatMap(IReadOnlyList<EntitySnapshot> hostiles)
|
||||
{
|
||||
if (hostiles.Count == 0) return new ThreatMap();
|
||||
|
||||
int close = 0, mid = 0, far = 0;
|
||||
float closest = float.MaxValue;
|
||||
var weightedSum = Vector2.Zero;
|
||||
bool hasRareOrUnique = false;
|
||||
|
||||
foreach (var m in hostiles)
|
||||
{
|
||||
var d = m.DistanceToPlayer;
|
||||
if (d < closest) closest = d;
|
||||
|
||||
if (d < 300f) close++;
|
||||
else if (d < 600f) mid++;
|
||||
else if (d < 1200f) far++;
|
||||
|
||||
weightedSum += m.Position;
|
||||
|
||||
if (m.ThreatLevel is MonsterThreatLevel.Rare or MonsterThreatLevel.Unique)
|
||||
hasRareOrUnique = true;
|
||||
}
|
||||
|
||||
return new ThreatMap
|
||||
{
|
||||
TotalHostiles = hostiles.Count,
|
||||
CloseRange = close,
|
||||
MidRange = mid,
|
||||
FarRange = far,
|
||||
ClosestDistance = closest,
|
||||
ThreatCentroid = weightedSum / hostiles.Count,
|
||||
HasRareOrUnique = hasRareOrUnique,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes danger using effective HP (life + energy shield) and a weighted threat score.
|
||||
/// Close enemies count more, rares/uniques escalate significantly.
|
||||
/// Hysteresis: de-escalation requires a larger margin than escalation to prevent oscillation.
|
||||
/// </summary>
|
||||
private static DangerLevel _previousDanger = DangerLevel.Safe;
|
||||
private static float _smoothedThreatScore;
|
||||
private static long _lastEscalationMs;
|
||||
|
||||
private static DangerLevel ComputeDangerLevel(GameState state)
|
||||
{
|
||||
// Effective HP = life + ES combined
|
||||
var effectiveHp = state.Player.LifeCurrent + state.Player.EsCurrent;
|
||||
var effectiveMax = state.Player.LifeTotal + state.Player.EsTotal;
|
||||
var effectivePercent = effectiveMax > 0 ? (float)effectiveHp / effectiveMax * 100f : 0f;
|
||||
|
||||
// Pure life check — if ES is gone and life is low, it's critical (no hysteresis)
|
||||
if (state.Player.LifePercent < 25f)
|
||||
{
|
||||
_previousDanger = DangerLevel.Critical;
|
||||
return DangerLevel.Critical;
|
||||
}
|
||||
if (effectivePercent < 35f)
|
||||
{
|
||||
_previousDanger = DangerLevel.Critical;
|
||||
return DangerLevel.Critical;
|
||||
}
|
||||
if (effectivePercent < 50f)
|
||||
{
|
||||
var hpLevel = DangerLevel.High;
|
||||
if (hpLevel < _previousDanger)
|
||||
hpLevel = _previousDanger; // don't de-escalate from HP alone
|
||||
_previousDanger = hpLevel;
|
||||
return hpLevel;
|
||||
}
|
||||
|
||||
// Weighted threat score: smooth distance falloff × rarity multiplier
|
||||
var threatScore = 0f;
|
||||
foreach (var m in state.HostileMonsters)
|
||||
{
|
||||
var d = m.DistanceToPlayer;
|
||||
if (d > 800f) continue;
|
||||
|
||||
// Smooth distance weight: linear falloff from 3.0 at d=0 to 0.5 at d=800
|
||||
var distWeight = 3f - 2.5f * (d / 800f);
|
||||
|
||||
// Rarity multiplier
|
||||
var rarityMul = m.Rarity switch
|
||||
{
|
||||
MonsterRarity.Unique => 5f,
|
||||
MonsterRarity.Rare => 3f,
|
||||
MonsterRarity.Magic => 1.5f,
|
||||
_ => 1f,
|
||||
};
|
||||
|
||||
threatScore += distWeight * rarityMul;
|
||||
}
|
||||
|
||||
// EMA smoothing — prevents single-frame score spikes from causing oscillation.
|
||||
// Snap upward (escalation is instant), smooth downward (de-escalation is gradual).
|
||||
const float deescalationAlpha = 0.08f;
|
||||
if (threatScore >= _smoothedThreatScore)
|
||||
_smoothedThreatScore = threatScore; // snap up — react instantly to new threats
|
||||
else
|
||||
_smoothedThreatScore += (threatScore - _smoothedThreatScore) * deescalationAlpha;
|
||||
threatScore = _smoothedThreatScore;
|
||||
|
||||
// Escalation thresholds
|
||||
var level = DangerLevel.Safe;
|
||||
if (threatScore >= 15f) level = DangerLevel.Critical;
|
||||
else if (threatScore >= 8f) level = DangerLevel.High;
|
||||
else if (threatScore >= 4f) level = DangerLevel.Medium;
|
||||
else if (threatScore > 0f) level = DangerLevel.Low;
|
||||
|
||||
// Hysteresis: minimum hold time + score margins prevent oscillation
|
||||
var now = Environment.TickCount64;
|
||||
if (level != _previousDanger)
|
||||
{
|
||||
// Hold any level for at least 1.5 seconds before allowing ANY transition
|
||||
if (now - _lastEscalationMs < 1500)
|
||||
{
|
||||
level = _previousDanger;
|
||||
}
|
||||
else if (level < _previousDanger)
|
||||
{
|
||||
// Score-based hysteresis — only drop one level at a time
|
||||
if (_previousDanger >= DangerLevel.Critical)
|
||||
level = DangerLevel.High;
|
||||
else if (_previousDanger >= DangerLevel.High)
|
||||
level = DangerLevel.Medium;
|
||||
else if (_previousDanger >= DangerLevel.Medium && threatScore >= 2f)
|
||||
level = DangerLevel.Medium;
|
||||
}
|
||||
}
|
||||
|
||||
// Track any transition
|
||||
if (level != _previousDanger)
|
||||
_lastEscalationMs = now;
|
||||
|
||||
_previousDanger = level;
|
||||
return level;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ public class SimConfig
|
|||
public float EnemyAggroRange { get; set; } = 600f;
|
||||
public float EnemyMeleeAttackRange { get; set; } = 100f;
|
||||
public float EnemyMoveSpeedFactor { get; set; } = 0.75f;
|
||||
public int EnemyBaseHealth { get; set; } = 200;
|
||||
public int EnemyBaseHealth { get; set; } = 500;
|
||||
public int EnemyMeleeBaseDamage { get; set; } = 60;
|
||||
public float EnemyMeleeAttackCooldown { get; set; } = 1.2f;
|
||||
|
||||
|
|
@ -42,8 +42,8 @@ public class SimConfig
|
|||
public float EnemySpawnMinDist { get; set; } = 800f;
|
||||
public float EnemySpawnMaxDist { get; set; } = 2000f;
|
||||
public float EnemyCullDist { get; set; } = 3000f;
|
||||
public int EnemyGroupMin { get; set; } = 3;
|
||||
public int EnemyGroupMax { get; set; } = 7;
|
||||
public int EnemyGroupMin { get; set; } = 7;
|
||||
public int EnemyGroupMax { get; set; } = 18;
|
||||
public float EnemyGroupSpread { get; set; } = 120f;
|
||||
|
||||
// Player skills
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ Log.Logger = new LoggerConfiguration()
|
|||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
|
||||
// Parse --ticks N for headless auto-exit
|
||||
int? maxTicks = null;
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
if (args[i] == "--ticks" && int.TryParse(args[i + 1], out var t))
|
||||
maxTicks = t;
|
||||
|
||||
Log.Information("Nexus Simulator starting...");
|
||||
|
||||
// ── Configuration ──
|
||||
|
|
@ -75,6 +81,7 @@ var movementBlender = new MovementBlender();
|
|||
var moveTracker = new MovementKeyTracker();
|
||||
var botRunning = true;
|
||||
var lastStatusLogMs = 0L;
|
||||
var botTickCount = 0;
|
||||
|
||||
var botThread = new Thread(() =>
|
||||
{
|
||||
|
|
@ -91,6 +98,13 @@ var botThread = new Thread(() =>
|
|||
var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
|
||||
ActionExecutor.Execute(resolved, input, moveTracker, movementBlender, state.Player.Position);
|
||||
|
||||
botTickCount++;
|
||||
if (maxTicks.HasValue && botTickCount >= maxTicks.Value)
|
||||
{
|
||||
botRunning = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Periodic status log (every 2 seconds)
|
||||
var nowMs = Environment.TickCount64;
|
||||
if (nowMs - lastStatusLogMs >= 2000)
|
||||
|
|
@ -110,12 +124,15 @@ var botThread = new Thread(() =>
|
|||
}));
|
||||
if (actions.Length == 0) actions = "none";
|
||||
|
||||
var ta = state.ThreatAssessment;
|
||||
Log.Information(
|
||||
"Status: HP={HP}/{MaxHP} ES={ES}/{MaxES} Mana={MP}/{MaxMP} Danger={Danger} " +
|
||||
"Status: HP={HP}/{MaxHP} ES={ES}/{MaxES} Mana={MP}/{MaxMP} " +
|
||||
"Threat={Zone:F1}(fw={Fw:F2}) " +
|
||||
"Enemies={Total}({Melee}m/{Ranged}r) Nav={NavMode} Actions=[{Actions}] " +
|
||||
"Move=[{Blender}]",
|
||||
p.LifeCurrent, p.LifeTotal, p.EsCurrent, p.EsTotal, p.ManaCurrent, p.ManaTotal,
|
||||
state.Danger, melee + ranged, melee, ranged,
|
||||
ta.ZoneThreatLevel, ta.FleeWeight,
|
||||
melee + ranged, melee, ranged,
|
||||
nav.Mode, actions, movementBlender.DiagnosticSummary());
|
||||
}
|
||||
}
|
||||
|
|
@ -172,7 +189,7 @@ window.Resized += () =>
|
|||
var renderSw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var lastRenderMs = 0.0;
|
||||
|
||||
while (window.Exists)
|
||||
while (window.Exists && botRunning)
|
||||
{
|
||||
var nowMs = renderSw.Elapsed.TotalMilliseconds;
|
||||
var deltaSeconds = (float)((nowMs - lastRenderMs) / 1000.0);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ public class DebugPanel
|
|||
|
||||
private int _spawnRarity; // 0=Normal, 1=Magic, 2=Rare, 3=Unique
|
||||
|
||||
// Cached threat display — updated at ~4Hz to prevent visual flicker
|
||||
private long _lastThreatUpdateMs;
|
||||
private string _cachedZoneLine = "";
|
||||
private string _cachedDangerLine = "";
|
||||
private string _cachedRangeLine = "";
|
||||
private string _cachedClosestLine = "";
|
||||
private string _cachedTopThreatLine = "Top Threat: None";
|
||||
private string _cachedKillTargetLine = "Kill Target: None";
|
||||
|
||||
public DebugPanel(SimConfig config, SimWorld world, NavigationController nav, IReadOnlyList<ISystem> systems)
|
||||
{
|
||||
_config = config;
|
||||
|
|
@ -108,14 +117,32 @@ public class DebugPanel
|
|||
}
|
||||
}
|
||||
|
||||
// Threat info
|
||||
if (state is not null && ImGui.CollapsingHeader("Threat"))
|
||||
// Threat info — cached at ~4Hz to prevent visual flicker
|
||||
if (state is not null && ImGui.CollapsingHeader("Threat", ImGuiTreeNodeFlags.DefaultOpen))
|
||||
{
|
||||
ImGui.Text($"Danger: {state.Danger}");
|
||||
var threats = state.Threats;
|
||||
ImGui.Text($"Close: {threats.CloseRange} Mid: {threats.MidRange} Far: {threats.FarRange}");
|
||||
ImGui.Text($"Closest: {threats.ClosestDistance:F0}");
|
||||
ImGui.Text($"Has Rare/Unique: {threats.HasRareOrUnique}");
|
||||
var nowMs = Environment.TickCount64;
|
||||
if (nowMs - _lastThreatUpdateMs >= 250)
|
||||
{
|
||||
_lastThreatUpdateMs = nowMs;
|
||||
var ta = state.ThreatAssessment;
|
||||
_cachedZoneLine = $"Zone Threat: {ta.ZoneThreatLevel:F1} Flee Weight: {ta.FleeWeight:F2}";
|
||||
_cachedDangerLine = $"Danger: {state.Danger} Flee: {ta.ShouldFlee} Emergency: {ta.AnyEmergency}";
|
||||
_cachedRangeLine = $"Close: {ta.CloseRange} Mid: {ta.MidRange} Far: {ta.FarRange}";
|
||||
_cachedClosestLine = $"Closest: {ta.ClosestDistance:F0} Rare/Unique: {ta.HasRareOrUnique}";
|
||||
_cachedTopThreatLine = ta.MostDangerous is { } top
|
||||
? $"Top Threat: #{top.EntityId} ({top.Rarity}) score={top.ThreatScore:F1} cat={top.Category}"
|
||||
: "Top Threat: None";
|
||||
_cachedKillTargetLine = ta.PrimaryTarget is { } pt
|
||||
? $"Kill Target: #{pt.EntityId} ({pt.Rarity}) HP={pt.HpPercent:P0} dist={pt.DistanceToPlayer:F0}"
|
||||
: "Kill Target: None";
|
||||
}
|
||||
|
||||
ImGui.Text(_cachedZoneLine);
|
||||
ImGui.Text(_cachedDangerLine);
|
||||
ImGui.Text(_cachedRangeLine);
|
||||
ImGui.Text(_cachedClosestLine);
|
||||
ImGui.Text(_cachedTopThreatLine);
|
||||
ImGui.Text(_cachedKillTargetLine);
|
||||
}
|
||||
|
||||
// Action queue
|
||||
|
|
|
|||
|
|
@ -176,7 +176,8 @@ public class SimRenderer
|
|||
|
||||
if (state is not null)
|
||||
{
|
||||
drawList.AddText(textPos, color, $"Danger: {state.Danger} Enemies: {state.HostileMonsters.Count}");
|
||||
var ta = state.ThreatAssessment;
|
||||
drawList.AddText(textPos, color, $"Threat: {ta.ZoneThreatLevel:F0} (fw={ta.FleeWeight:F1}) Enemies: {state.HostileMonsters.Count}");
|
||||
textPos.Y += 16;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ using Serilog;
|
|||
namespace Nexus.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// Emergency-only threat response. Runs first (priority 50).
|
||||
/// Only fires on Critical danger (low HP or overwhelming threat score).
|
||||
/// Normal combat (High/Medium) is handled by MovementSystem orbiting + CombatSystem herding.
|
||||
/// Per-entity threat scoring with continuous flee weights.
|
||||
/// Runs first (priority 50). Builds ThreatAssessment on GameState,
|
||||
/// then submits flee movement intents when warranted.
|
||||
/// </summary>
|
||||
public class ThreatSystem : ISystem
|
||||
{
|
||||
|
|
@ -15,54 +15,440 @@ public class ThreatSystem : ISystem
|
|||
public string Name => "Threat";
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>If closest enemy is within this range AND danger is Critical, escalate to urgent flee.</summary>
|
||||
public float PointBlankRange { get; set; } = 120f;
|
||||
|
||||
/// <summary>World-to-grid conversion factor for terrain queries.</summary>
|
||||
public float WorldToGrid { get; set; } = 23f / 250f;
|
||||
|
||||
private DangerLevel _prevDanger = DangerLevel.Safe;
|
||||
// ── Config ──
|
||||
public float MaxThreatRange { get; set; } = 900f;
|
||||
public float PointBlankRange { get; set; } = 120f;
|
||||
public float FleeThreshold { get; set; } = 180f;
|
||||
public float PanicThreshold { get; set; } = 350f;
|
||||
|
||||
// Weights for score composition
|
||||
private const float W_Distance = 1.0f;
|
||||
private const float W_Rarity = 1.0f;
|
||||
private const float W_PackSize = 0.3f;
|
||||
|
||||
// Score decay for smooth transitions
|
||||
private float _smoothedZoneThreat;
|
||||
private const float ZoneThreatUpAlpha = 0.12f; // ramp up over ~8 ticks (~130ms)
|
||||
private const float ZoneThreatDownAlpha = 0.04f; // decay over ~25 ticks (~400ms)
|
||||
|
||||
// Top-threat debounce — locked entity must lose for N consecutive ticks before switching
|
||||
private uint? _lockedTopThreatId;
|
||||
private int _loseStreak;
|
||||
private const int TopThreatDebounce = 20; // ~330ms at 60Hz
|
||||
|
||||
// Kill-target debounce
|
||||
private uint? _lockedKillTargetId;
|
||||
private int _killLoseStreak;
|
||||
private const int KillTargetDebounce = 15; // ~250ms
|
||||
|
||||
// Logging
|
||||
private ThreatCategory _prevMaxCategory = ThreatCategory.Ignore;
|
||||
private uint? _prevTopThreatId;
|
||||
|
||||
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
|
||||
{
|
||||
if (!state.Player.HasPosition) return;
|
||||
|
||||
var danger = state.Danger;
|
||||
var threats = state.Threats;
|
||||
var playerPos = state.Player.Position;
|
||||
var playerHpFactor = 1f + (1f - state.Player.LifePercent / 100f) * 1.5f;
|
||||
|
||||
// Log danger transitions
|
||||
if (danger != _prevDanger)
|
||||
// ── 1. Score each hostile ──
|
||||
var entries = new List<ThreatEntry>(state.HostileMonsters.Count);
|
||||
|
||||
foreach (var monster in state.HostileMonsters)
|
||||
{
|
||||
if (danger >= DangerLevel.High)
|
||||
Log.Warning("Threat: {Prev} -> {Cur} (hostiles={Total}, close={Close}, closest={Dist:F0})",
|
||||
_prevDanger, danger, threats.TotalHostiles, threats.CloseRange, threats.ClosestDistance);
|
||||
else
|
||||
Log.Debug("Threat: {Prev} -> {Cur}", _prevDanger, danger);
|
||||
_prevDanger = danger;
|
||||
if (!monster.IsAlive) continue;
|
||||
|
||||
var entry = ScoreEntity(monster, playerPos, playerHpFactor, state);
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
// Only respond to Critical danger — High is normal combat, handled by orbit/herd
|
||||
if (danger != DangerLevel.Critical) return;
|
||||
if (threats.TotalHostiles == 0) return;
|
||||
|
||||
// Compute flee direction: away from threat centroid
|
||||
var fleeDir = state.Player.Position - threats.ThreatCentroid;
|
||||
if (fleeDir.LengthSquared() < 0.0001f)
|
||||
fleeDir = Vector2.UnitY;
|
||||
|
||||
fleeDir = Vector2.Normalize(fleeDir);
|
||||
|
||||
var isPointBlank = threats.ClosestDistance < PointBlankRange;
|
||||
|
||||
if (isPointBlank)
|
||||
// ── 2. Pack context pass — count nearby allies per monster (capped) ──
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
// Layer 0: total override — pure flee, blocks casting
|
||||
movement.Submit(new MovementIntent(0, fleeDir, 1.0f, "Threat"));
|
||||
var nearby = 0;
|
||||
for (var j = 0; j < entries.Count; j++)
|
||||
{
|
||||
if (i == j) continue;
|
||||
if (Vector2.DistanceSquared(entries[i].Position, entries[j].Position) < 600f * 600f)
|
||||
nearby++;
|
||||
}
|
||||
// Cap pack bonus at 5 allies so 50-mob groups don't dominate the score
|
||||
entries[i].ThreatScore += Math.Min(nearby, 5) * W_PackSize;
|
||||
}
|
||||
|
||||
// ── 3. Classify each entry ──
|
||||
var anyEmergency = false;
|
||||
ThreatEntry? rawTopThreat = null;
|
||||
ThreatEntry? lockedEntry = null;
|
||||
var closestDist = 0f;
|
||||
var closestFound = false;
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
entry.Category = Classify(entry, state.Player);
|
||||
entry.PerceivedDanger = Math.Clamp(entry.ThreatScore / PanicThreshold, 0f, 1f);
|
||||
|
||||
if (entry.Category == ThreatCategory.Emergency)
|
||||
anyEmergency = true;
|
||||
|
||||
if (rawTopThreat is null || entry.ThreatScore > rawTopThreat.ThreatScore
|
||||
|| (entry.ThreatScore == rawTopThreat.ThreatScore && entry.EntityId < rawTopThreat.EntityId))
|
||||
rawTopThreat = entry;
|
||||
|
||||
if (_lockedTopThreatId.HasValue && entry.EntityId == _lockedTopThreatId.Value)
|
||||
lockedEntry = entry;
|
||||
|
||||
if (!closestFound || entry.DistanceToPlayer < closestDist)
|
||||
{
|
||||
closestDist = entry.DistanceToPlayer;
|
||||
closestFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce top-threat: locked entity stays until it's clearly outclassed for N ticks
|
||||
ThreatEntry? mostDangerous;
|
||||
if (lockedEntry is null)
|
||||
{
|
||||
// Locked entity is dead/despawned — accept raw winner immediately
|
||||
mostDangerous = rawTopThreat;
|
||||
_lockedTopThreatId = rawTopThreat?.EntityId;
|
||||
_loseStreak = 0;
|
||||
}
|
||||
else if (rawTopThreat is not null && rawTopThreat.EntityId != _lockedTopThreatId)
|
||||
{
|
||||
// Only count as "losing" if the locked entry is significantly behind (>30%)
|
||||
var significantlyBehind = rawTopThreat.ThreatScore > lockedEntry.ThreatScore * 1.3f;
|
||||
if (significantlyBehind)
|
||||
_loseStreak++;
|
||||
// Still count slow drift if locked is behind at all, but at half rate
|
||||
else if (rawTopThreat.ThreatScore > lockedEntry.ThreatScore)
|
||||
_loseStreak = Math.Max(0, _loseStreak); // don't reset, just don't increment
|
||||
else
|
||||
_loseStreak = Math.Max(0, _loseStreak - 1); // locked is actually winning, cool down
|
||||
|
||||
if (_loseStreak >= TopThreatDebounce)
|
||||
{
|
||||
mostDangerous = rawTopThreat;
|
||||
_lockedTopThreatId = rawTopThreat.EntityId;
|
||||
_loseStreak = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
mostDangerous = lockedEntry;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Layer 1: strong flee but allow some nav/orbit bleed-through
|
||||
movement.Submit(new MovementIntent(1, fleeDir, 0.6f, "Threat"));
|
||||
// Locked entity is still the raw winner — cool down streak
|
||||
mostDangerous = lockedEntry;
|
||||
_loseStreak = Math.Max(0, _loseStreak - 2); // cool down faster when winning
|
||||
}
|
||||
|
||||
// ── Log top threat changes ──
|
||||
var newTopId = mostDangerous?.EntityId;
|
||||
if (newTopId != _prevTopThreatId)
|
||||
{
|
||||
if (mostDangerous is not null)
|
||||
Log.Information("TopThreat: #{Id} ({Rarity}) score={Score:F1} cat={Cat} dist={Dist:F0} hp={Hp:P0} (prev=#{Prev})",
|
||||
mostDangerous.EntityId, mostDangerous.Rarity, mostDangerous.ThreatScore,
|
||||
mostDangerous.Category, mostDangerous.DistanceToPlayer, mostDangerous.HpPercent,
|
||||
_prevTopThreatId?.ToString() ?? "none");
|
||||
else
|
||||
Log.Information("TopThreat: cleared (prev=#{Prev})", _prevTopThreatId?.ToString() ?? "none");
|
||||
|
||||
_prevTopThreatId = newTopId;
|
||||
}
|
||||
|
||||
// ── 4. Aggregate into ThreatAssessment ──
|
||||
var rawZone = 0f;
|
||||
foreach (var e in entries)
|
||||
rawZone += e.ThreatScore;
|
||||
|
||||
// Smooth zone threat — fast up, slow down
|
||||
if (rawZone >= _smoothedZoneThreat)
|
||||
_smoothedZoneThreat += (rawZone - _smoothedZoneThreat) * ZoneThreatUpAlpha;
|
||||
else
|
||||
_smoothedZoneThreat += (rawZone - _smoothedZoneThreat) * ZoneThreatDownAlpha;
|
||||
|
||||
var centroid = ComputeThreatCentroid(entries, playerPos);
|
||||
var safest = playerPos - centroid;
|
||||
if (safest.LengthSquared() < 0.0001f) safest = Vector2.UnitY;
|
||||
safest = Vector2.Normalize(safest);
|
||||
|
||||
// Hysteresis on flee transition — require score to drop 15% below threshold to de-escalate
|
||||
var wasFleeing = _prevMaxCategory >= ThreatCategory.Flee;
|
||||
var fleeOffThreshold = wasFleeing ? FleeThreshold * 0.85f : FleeThreshold;
|
||||
var shouldFlee = _smoothedZoneThreat > fleeOffThreshold || anyEmergency;
|
||||
var areaClear = entries.TrueForAll(e => e.Category < ThreatCategory.Monitor);
|
||||
|
||||
// Range band counts (backward compat)
|
||||
int close = 0, mid = 0, far = 0;
|
||||
bool hasRareOrUnique = false;
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e.DistanceToPlayer < 300f) close++;
|
||||
else if (e.DistanceToPlayer < 600f) mid++;
|
||||
else if (e.DistanceToPlayer < 1200f) far++;
|
||||
if (e.Rarity >= MonsterRarity.Rare) hasRareOrUnique = true;
|
||||
}
|
||||
|
||||
var assessment = new ThreatAssessment
|
||||
{
|
||||
Entries = entries,
|
||||
ZoneThreatLevel = _smoothedZoneThreat,
|
||||
PrimaryTarget = SelectPrimaryTarget(entries),
|
||||
MostDangerous = mostDangerous,
|
||||
ThreatCentroid = centroid,
|
||||
SafestDirection = safest,
|
||||
AnyEmergency = anyEmergency,
|
||||
ShouldFlee = shouldFlee,
|
||||
AreaClear = areaClear,
|
||||
ClosestDistance = closestDist,
|
||||
FleeWeight = Math.Clamp(_smoothedZoneThreat / PanicThreshold, 0f, 1f),
|
||||
CloseRange = close,
|
||||
MidRange = mid,
|
||||
FarRange = far,
|
||||
HasRareOrUnique = hasRareOrUnique,
|
||||
};
|
||||
|
||||
// Debounce kill target — same lose-streak pattern
|
||||
var rawKillTarget = assessment.PrimaryTarget;
|
||||
var lockedKillEntry = _lockedKillTargetId.HasValue
|
||||
? entries.FirstOrDefault(e => e.EntityId == _lockedKillTargetId.Value
|
||||
&& e.Category >= ThreatCategory.Engage && e.HasLineOfSight)
|
||||
: null;
|
||||
|
||||
if (lockedKillEntry is null)
|
||||
{
|
||||
_lockedKillTargetId = rawKillTarget?.EntityId;
|
||||
_killLoseStreak = 0;
|
||||
}
|
||||
else if (rawKillTarget is not null && rawKillTarget.EntityId != _lockedKillTargetId)
|
||||
{
|
||||
_killLoseStreak++;
|
||||
if (_killLoseStreak >= KillTargetDebounce)
|
||||
{
|
||||
_lockedKillTargetId = rawKillTarget.EntityId;
|
||||
_killLoseStreak = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
assessment.PrimaryTarget = lockedKillEntry;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_killLoseStreak = 0;
|
||||
}
|
||||
|
||||
state.ThreatAssessment = assessment;
|
||||
|
||||
// Backward compat — keep DangerLevel for consumers that still read it
|
||||
state.Danger = ToDangerLevel(assessment);
|
||||
state.Threats = new ThreatMap
|
||||
{
|
||||
TotalHostiles = entries.Count,
|
||||
CloseRange = close,
|
||||
MidRange = mid,
|
||||
FarRange = far,
|
||||
ClosestDistance = closestDist,
|
||||
ThreatCentroid = centroid,
|
||||
HasRareOrUnique = hasRareOrUnique,
|
||||
};
|
||||
|
||||
// ── 5. Log zone-level transitions with hysteresis ──
|
||||
// Each threshold has a lower de-escalation point (15% below) to prevent bouncing
|
||||
var zoneCat = ClassifyZone(_smoothedZoneThreat, anyEmergency, shouldFlee, _prevMaxCategory);
|
||||
|
||||
if (zoneCat != _prevMaxCategory)
|
||||
{
|
||||
if (zoneCat >= ThreatCategory.Flee)
|
||||
Log.Warning("Threat: {Prev} -> {Cur} (zone={Zone:F1}, closest={Dist:F0}, hostiles={Count})",
|
||||
_prevMaxCategory, zoneCat, _smoothedZoneThreat, closestDist, entries.Count);
|
||||
else
|
||||
Log.Debug("Threat: {Prev} -> {Cur} (zone={Zone:F1})",
|
||||
_prevMaxCategory, zoneCat, _smoothedZoneThreat);
|
||||
_prevMaxCategory = zoneCat;
|
||||
}
|
||||
|
||||
// ── 6. Submit movement intents ──
|
||||
if (!shouldFlee) return;
|
||||
|
||||
var isPointBlank = closestDist < PointBlankRange;
|
||||
|
||||
if (anyEmergency || isPointBlank)
|
||||
{
|
||||
// Layer 0: near-total override — flee, blocks casting. 0.85 lets wall push still help.
|
||||
movement.Submit(new MovementIntent(0, safest, 0.85f, "Threat"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Layer 1: strong flee scaled by flee weight
|
||||
var override1 = 0.3f + assessment.FleeWeight * 0.4f; // 0.3–0.7
|
||||
movement.Submit(new MovementIntent(1, safest * assessment.FleeWeight, override1, "Threat"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-entity scoring ──
|
||||
|
||||
private ThreatEntry ScoreEntity(EntitySnapshot monster, Vector2 playerPos, float playerHpFactor, GameState state)
|
||||
{
|
||||
var dist = monster.DistanceToPlayer;
|
||||
var score = 0f;
|
||||
|
||||
// Distance — nonlinear: spikes sharply as enemies close in
|
||||
var distFactor = DistanceFactor(dist);
|
||||
score += distFactor * W_Distance;
|
||||
|
||||
// Rarity — scaled by distance so far-away rares/uniques don't dominate
|
||||
var rarityBase = monster.Rarity switch
|
||||
{
|
||||
MonsterRarity.Unique => 3f,
|
||||
MonsterRarity.Rare => 2f,
|
||||
MonsterRarity.Magic => 1.3f,
|
||||
_ => 1f,
|
||||
};
|
||||
// At max range: rarity contributes 20% of its base; at close range: 100%
|
||||
var rarityScale = 0.2f + 0.8f * Math.Clamp(distFactor / 10f, 0f, 1f);
|
||||
score += rarityBase * rarityScale * W_Rarity;
|
||||
|
||||
// LOS — de-weight monsters behind walls
|
||||
var hasLos = true;
|
||||
if (state.Terrain is { } terrain)
|
||||
{
|
||||
hasLos = TerrainQuery.HasLineOfSight(terrain, playerPos, monster.Position, WorldToGrid);
|
||||
if (!hasLos) score *= 0.4f;
|
||||
}
|
||||
|
||||
// Low HP monsters are less threatening
|
||||
var hpPct = monster.LifeTotal > 0 ? (float)monster.LifeCurrent / monster.LifeTotal : 1f;
|
||||
if (hpPct < 0.1f) score *= 0.3f;
|
||||
|
||||
// Player HP context — same monster is scarier when you're low
|
||||
score *= playerHpFactor;
|
||||
|
||||
return new ThreatEntry
|
||||
{
|
||||
EntityId = monster.Id,
|
||||
Position = monster.Position,
|
||||
DistanceToPlayer = dist,
|
||||
ThreatScore = score,
|
||||
HasLineOfSight = hasLos,
|
||||
Rarity = monster.Rarity,
|
||||
HpPercent = hpPct,
|
||||
IsAlive = true,
|
||||
};
|
||||
}
|
||||
|
||||
private float DistanceFactor(float dist)
|
||||
{
|
||||
if (dist > MaxThreatRange) return 0f;
|
||||
var t = 1f - dist / MaxThreatRange;
|
||||
return t * t * 10f; // inverse square: 10 @ 0, 2.5 @ 450, 0 @ 900
|
||||
}
|
||||
|
||||
// ── Classification ──
|
||||
|
||||
private ThreatCategory Classify(ThreatEntry entry, PlayerState player)
|
||||
{
|
||||
// Emergency overrides — certain conditions always trigger
|
||||
if (player.LifePercent < 25f && entry.ThreatScore > 8f)
|
||||
return ThreatCategory.Emergency;
|
||||
|
||||
return entry.ThreatScore switch
|
||||
{
|
||||
> 20f => ThreatCategory.Flee,
|
||||
> 8f => ThreatCategory.Engage,
|
||||
> 3f => ThreatCategory.Monitor,
|
||||
> 0f => ThreatCategory.Ignore,
|
||||
_ => ThreatCategory.Ignore,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Centroid (score-weighted) ──
|
||||
|
||||
private static Vector2 ComputeThreatCentroid(List<ThreatEntry> entries, Vector2 playerPos)
|
||||
{
|
||||
var totalWeight = 0f;
|
||||
var weighted = Vector2.Zero;
|
||||
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e.Category < ThreatCategory.Monitor) continue;
|
||||
weighted += e.Position * e.ThreatScore;
|
||||
totalWeight += e.ThreatScore;
|
||||
}
|
||||
|
||||
return totalWeight > 0f ? weighted / totalWeight : playerPos;
|
||||
}
|
||||
|
||||
// ── Target selection (kill priority, not raw threat) ──
|
||||
|
||||
private static ThreatEntry? SelectPrimaryTarget(List<ThreatEntry> entries)
|
||||
{
|
||||
ThreatEntry? best = null;
|
||||
var bestScore = float.MinValue;
|
||||
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e.Category < ThreatCategory.Engage) continue;
|
||||
if (!e.HasLineOfSight) continue;
|
||||
|
||||
var score = 0f;
|
||||
|
||||
// Prefer low HP targets — finish them off
|
||||
score += (1f - e.HpPercent) * 3f;
|
||||
|
||||
// Prefer closer targets
|
||||
score += (1f - Math.Clamp(e.DistanceToPlayer / 800f, 0f, 1f)) * 2f;
|
||||
|
||||
// Prefer dangerous rarity
|
||||
if (e.Rarity >= MonsterRarity.Rare) score += 2f;
|
||||
if (e.Rarity == MonsterRarity.Unique) score += 3f;
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = e;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── Backward compat ──
|
||||
|
||||
private static ThreatCategory ClassifyZone(float zone, bool anyEmergency, bool shouldFlee, ThreatCategory prev)
|
||||
{
|
||||
if (anyEmergency) return ThreatCategory.Emergency;
|
||||
if (shouldFlee) return ThreatCategory.Flee;
|
||||
|
||||
// Escalation thresholds / de-escalation thresholds (15% gap)
|
||||
// Engage: up at 100, down at 85
|
||||
// Monitor: up at 30, down at 25
|
||||
// Ignore: up at 5
|
||||
return prev switch
|
||||
{
|
||||
ThreatCategory.Engage when zone >= 85f => ThreatCategory.Engage,
|
||||
ThreatCategory.Monitor when zone >= 100f => ThreatCategory.Engage,
|
||||
ThreatCategory.Monitor when zone >= 25f => ThreatCategory.Monitor,
|
||||
_ when zone >= 100f => ThreatCategory.Engage,
|
||||
_ when zone >= 30f => ThreatCategory.Monitor,
|
||||
_ when zone > 5f => ThreatCategory.Ignore,
|
||||
_ => ThreatCategory.Ignore,
|
||||
};
|
||||
}
|
||||
|
||||
private static DangerLevel ToDangerLevel(ThreatAssessment a)
|
||||
{
|
||||
if (a.AnyEmergency) return DangerLevel.Critical;
|
||||
if (a.ShouldFlee) return DangerLevel.Critical;
|
||||
if (a.FleeWeight > 0.5f) return DangerLevel.High;
|
||||
if (a.FleeWeight > 0.15f) return DangerLevel.Medium;
|
||||
if (a.ZoneThreatLevel > 5f) return DangerLevel.Low;
|
||||
return DangerLevel.Safe;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue