feat(l0): implement combat logic core and geometry intersections
This commit is contained in:
parent
227c3878ae
commit
01f79dccfa
|
|
@ -0,0 +1,17 @@
|
||||||
|
using Nightborn.Core.Geometry;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Combat
|
||||||
|
{
|
||||||
|
public struct AttackData
|
||||||
|
{
|
||||||
|
public int AttackId { get; set; }
|
||||||
|
public Vector3 Origin { get; set; }
|
||||||
|
public float DirectionDegrees { get; set; }
|
||||||
|
public Shape HitShape { get; set; }
|
||||||
|
public FormType SourceForm { get; set; }
|
||||||
|
public float BaseDamage { get; set; }
|
||||||
|
public float CritChance { get; set; }
|
||||||
|
public float CritMultiplier { get; set; }
|
||||||
|
public float KnockbackForce { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Nightborn.Core.Geometry;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Combat
|
||||||
|
{
|
||||||
|
public sealed class CombatLogic
|
||||||
|
{
|
||||||
|
private sealed class CandidateHit
|
||||||
|
{
|
||||||
|
public int AttackKey { get; set; }
|
||||||
|
public int TargetId { get; set; }
|
||||||
|
public float FinalDamage { get; set; }
|
||||||
|
public bool IsCritical { get; set; }
|
||||||
|
public FormType SourceForm { get; set; }
|
||||||
|
public float KnockbackDistance { get; set; }
|
||||||
|
public Vector3 HitPoint { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IRandomProvider _randomProvider;
|
||||||
|
|
||||||
|
public CombatLogic(IRandomProvider? randomProvider = null)
|
||||||
|
{
|
||||||
|
_randomProvider = randomProvider ?? new DefaultRandomProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action<DamageResult>? OnHit;
|
||||||
|
public event Action<DamageResult>? OnKill;
|
||||||
|
public event Action<DamageResult>? OnCrit;
|
||||||
|
public event Action<AttackData, int>? OnAttackResolved;
|
||||||
|
|
||||||
|
public static bool TestHit(Shape a, Shape b)
|
||||||
|
{
|
||||||
|
return ShapeIntersection.Intersects(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float CalculateKnockbackDistance(float knockbackForce, float enemyWeight, bool isCritical)
|
||||||
|
{
|
||||||
|
if (knockbackForce <= 0f)
|
||||||
|
{
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeWeight = enemyWeight <= MathUtil.Epsilon ? 1f : enemyWeight;
|
||||||
|
var criticalFactor = isCritical ? 1.5f : 1f;
|
||||||
|
return (knockbackForce / safeWeight) * criticalFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DamageResult> ResolveAttacks(IReadOnlyList<AttackData> attacks, IReadOnlyList<EnemyState> enemies, float deltaTime)
|
||||||
|
{
|
||||||
|
var results = new List<DamageResult>();
|
||||||
|
if (deltaTime <= 0f || attacks == null || enemies == null || attacks.Count == 0 || enemies.Count == 0)
|
||||||
|
{
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
var enemyById = new Dictionary<int, EnemyState>();
|
||||||
|
for (var i = 0; i < enemies.Count; i++)
|
||||||
|
{
|
||||||
|
var enemy = enemies[i];
|
||||||
|
if (enemy == null || enemy.IsDead || enemy.HitShape == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enemyById.ContainsKey(enemy.Id))
|
||||||
|
{
|
||||||
|
enemyById.Add(enemy.Id, enemy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateHits = new List<CandidateHit>();
|
||||||
|
var seenPairs = new HashSet<long>();
|
||||||
|
var hitCountByAttackKey = new Dictionary<int, int>();
|
||||||
|
var attackByKey = new Dictionary<int, AttackData>();
|
||||||
|
|
||||||
|
for (var i = 0; i < attacks.Count; i++)
|
||||||
|
{
|
||||||
|
var attack = attacks[i];
|
||||||
|
if (attack.HitShape == null || attack.HitShape.IsDegenerate())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var attackKey = attack.AttackId != 0 ? attack.AttackId : 1_000_000 + i;
|
||||||
|
attackByKey[attackKey] = attack;
|
||||||
|
if (!hitCountByAttackKey.ContainsKey(attackKey))
|
||||||
|
{
|
||||||
|
hitCountByAttackKey.Add(attackKey, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in enemyById)
|
||||||
|
{
|
||||||
|
var enemy = pair.Value;
|
||||||
|
if (enemy.IsDead || enemy.HitShape == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairKey = ((long)attackKey << 32) ^ (uint)enemy.Id;
|
||||||
|
if (seenPairs.Contains(pairKey))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ShapeIntersection.Intersects(attack.HitShape, enemy.HitShape))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenPairs.Add(pairKey);
|
||||||
|
|
||||||
|
var critChance = MathUtil.Clamp(attack.CritChance, 0f, 1f);
|
||||||
|
var critMultiplier = attack.CritMultiplier < 1f ? 1f : attack.CritMultiplier;
|
||||||
|
var isCritical = _randomProvider.NextFloat() < critChance;
|
||||||
|
var finalDamage = attack.BaseDamage * (isCritical ? critMultiplier : 1f);
|
||||||
|
|
||||||
|
var hit = new CandidateHit
|
||||||
|
{
|
||||||
|
AttackKey = attackKey,
|
||||||
|
TargetId = enemy.Id,
|
||||||
|
FinalDamage = finalDamage,
|
||||||
|
IsCritical = isCritical,
|
||||||
|
SourceForm = attack.SourceForm,
|
||||||
|
HitPoint = enemy.HitShape.Center,
|
||||||
|
KnockbackDistance = CalculateKnockbackDistance(attack.KnockbackForce, enemy.EnemyWeight, isCritical)
|
||||||
|
};
|
||||||
|
|
||||||
|
candidateHits.Add(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidateHits.Sort((a, b) => b.FinalDamage.CompareTo(a.FinalDamage));
|
||||||
|
|
||||||
|
for (var i = 0; i < candidateHits.Count; i++)
|
||||||
|
{
|
||||||
|
var hit = candidateHits[i];
|
||||||
|
if (!enemyById.TryGetValue(hit.TargetId, out var enemy) || enemy.IsDead)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
enemy.Health -= hit.FinalDamage;
|
||||||
|
var isKilling = enemy.Health <= 0f;
|
||||||
|
|
||||||
|
var result = new DamageResult
|
||||||
|
{
|
||||||
|
AttackId = hit.AttackKey,
|
||||||
|
TargetId = hit.TargetId,
|
||||||
|
FinalDamage = hit.FinalDamage,
|
||||||
|
IsCritical = hit.IsCritical,
|
||||||
|
HitPoint = hit.HitPoint,
|
||||||
|
IsKillingBlow = isKilling,
|
||||||
|
SourceForm = hit.SourceForm,
|
||||||
|
KnockbackDistance = hit.KnockbackDistance
|
||||||
|
};
|
||||||
|
|
||||||
|
results.Add(result);
|
||||||
|
hitCountByAttackKey[hit.AttackKey] = hitCountByAttackKey[hit.AttackKey] + 1;
|
||||||
|
|
||||||
|
OnHit?.Invoke(result);
|
||||||
|
if (result.IsCritical)
|
||||||
|
{
|
||||||
|
OnCrit?.Invoke(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.IsKillingBlow)
|
||||||
|
{
|
||||||
|
OnKill?.Invoke(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in attackByKey)
|
||||||
|
{
|
||||||
|
var key = pair.Key;
|
||||||
|
var attack = pair.Value;
|
||||||
|
var hitCount = hitCountByAttackKey.TryGetValue(key, out var count) ? count : 0;
|
||||||
|
OnAttackResolved?.Invoke(attack, hitCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Nightborn.Core.Geometry;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Combat
|
||||||
|
{
|
||||||
|
public struct DamageResult
|
||||||
|
{
|
||||||
|
public int AttackId { get; set; }
|
||||||
|
public int TargetId { get; set; }
|
||||||
|
public float FinalDamage { get; set; }
|
||||||
|
public bool IsCritical { get; set; }
|
||||||
|
public Vector3 HitPoint { get; set; }
|
||||||
|
public bool IsKillingBlow { get; set; }
|
||||||
|
public FormType SourceForm { get; set; }
|
||||||
|
public float KnockbackDistance { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Combat
|
||||||
|
{
|
||||||
|
public sealed class DefaultRandomProvider : IRandomProvider
|
||||||
|
{
|
||||||
|
private readonly Random _random = new Random();
|
||||||
|
|
||||||
|
public float NextFloat()
|
||||||
|
{
|
||||||
|
return (float)_random.NextDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
using Nightborn.Core.Geometry;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Combat
|
||||||
|
{
|
||||||
|
public sealed class EnemyState
|
||||||
|
{
|
||||||
|
public EnemyState(int id, float health, float enemyWeight, Shape? hitShape)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Health = health;
|
||||||
|
EnemyWeight = enemyWeight;
|
||||||
|
HitShape = hitShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
public float Health { get; set; }
|
||||||
|
|
||||||
|
public float EnemyWeight { get; }
|
||||||
|
|
||||||
|
public Shape? HitShape { get; }
|
||||||
|
|
||||||
|
public bool IsDead => Health <= 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace Nightborn.Core.Combat
|
||||||
|
{
|
||||||
|
public enum FormType
|
||||||
|
{
|
||||||
|
Human = 1,
|
||||||
|
Wolf = 2,
|
||||||
|
Mist = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Nightborn.Core.Combat
|
||||||
|
{
|
||||||
|
public interface IRandomProvider
|
||||||
|
{
|
||||||
|
float NextFloat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
namespace Nightborn.Core.Geometry
|
||||||
|
{
|
||||||
|
public sealed class Circle : Shape
|
||||||
|
{
|
||||||
|
public Circle(Vector3 center, float radius)
|
||||||
|
{
|
||||||
|
Center = center;
|
||||||
|
Radius = radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ShapeType Type => ShapeType.Circle;
|
||||||
|
|
||||||
|
public override Vector3 Center { get; }
|
||||||
|
|
||||||
|
public float Radius { get; }
|
||||||
|
|
||||||
|
public bool Contains(Vector3 point)
|
||||||
|
{
|
||||||
|
var distSq = MathUtil.DistanceXZSquared(Center, point);
|
||||||
|
var rSq = Radius * Radius;
|
||||||
|
return distSq < (rSq - MathUtil.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool IsDegenerate()
|
||||||
|
{
|
||||||
|
return Radius <= MathUtil.Epsilon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Geometry
|
||||||
|
{
|
||||||
|
public static class MathUtil
|
||||||
|
{
|
||||||
|
public const float Epsilon = 0.0001f;
|
||||||
|
|
||||||
|
public static float Clamp(float value, float min, float max)
|
||||||
|
{
|
||||||
|
if (value < min)
|
||||||
|
{
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value > max)
|
||||||
|
{
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float Lerp(float a, float b, float t)
|
||||||
|
{
|
||||||
|
return a + ((b - a) * Clamp(t, 0f, 1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float InverseLerp(float a, float b, float value)
|
||||||
|
{
|
||||||
|
if (Approximately(a, b))
|
||||||
|
{
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Clamp((value - a) / (b - a), 0f, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float Remap(float inMin, float inMax, float outMin, float outMax, float value)
|
||||||
|
{
|
||||||
|
var t = InverseLerp(inMin, inMax, value);
|
||||||
|
return Lerp(outMin, outMax, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Approximately(float a, float b)
|
||||||
|
{
|
||||||
|
return Math.Abs(a - b) < Epsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float Deg2Rad(float degrees)
|
||||||
|
{
|
||||||
|
return degrees * ((float)Math.PI / 180f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float DistanceXZSquared(Vector3 a, Vector3 b)
|
||||||
|
{
|
||||||
|
var dx = a.X - b.X;
|
||||||
|
var dz = a.Z - b.Z;
|
||||||
|
return (dx * dx) + (dz * dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Vector3 RotateAroundY(Vector3 point, Vector3 pivot, float degrees)
|
||||||
|
{
|
||||||
|
var rad = Deg2Rad(degrees);
|
||||||
|
var cos = (float)Math.Cos(rad);
|
||||||
|
var sin = (float)Math.Sin(rad);
|
||||||
|
|
||||||
|
var translatedX = point.X - pivot.X;
|
||||||
|
var translatedZ = point.Z - pivot.Z;
|
||||||
|
|
||||||
|
var rotatedX = (translatedX * cos) - (translatedZ * sin);
|
||||||
|
var rotatedZ = (translatedX * sin) + (translatedZ * cos);
|
||||||
|
|
||||||
|
return new Vector3(rotatedX + pivot.X, point.Y, rotatedZ + pivot.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float DeltaAngleDegrees(float from, float to)
|
||||||
|
{
|
||||||
|
var delta = Repeat(to - from, 360f);
|
||||||
|
if (delta > 180f)
|
||||||
|
{
|
||||||
|
delta -= 360f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float Repeat(float t, float length)
|
||||||
|
{
|
||||||
|
return t - ((float)Math.Floor(t / length) * length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
namespace Nightborn.Core.Geometry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 2D rectangle on XZ plane, rotated around Y axis.
|
||||||
|
/// Width = local X size, Height = local Z size.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Rect : Shape
|
||||||
|
{
|
||||||
|
public Rect(Vector3 center, float width, float height, float rotationDegrees)
|
||||||
|
{
|
||||||
|
Center = center;
|
||||||
|
Width = width;
|
||||||
|
Height = height;
|
||||||
|
RotationDegrees = rotationDegrees;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ShapeType Type => ShapeType.Rect;
|
||||||
|
|
||||||
|
public override Vector3 Center { get; }
|
||||||
|
|
||||||
|
public float Width { get; }
|
||||||
|
|
||||||
|
public float Height { get; }
|
||||||
|
|
||||||
|
public float RotationDegrees { get; }
|
||||||
|
|
||||||
|
public override bool IsDegenerate()
|
||||||
|
{
|
||||||
|
return Width <= MathUtil.Epsilon || Height <= MathUtil.Epsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Contains(Vector3 point)
|
||||||
|
{
|
||||||
|
var local = MathUtil.RotateAroundY(point, Center, -RotationDegrees);
|
||||||
|
var halfW = Width * 0.5f;
|
||||||
|
var halfH = Height * 0.5f;
|
||||||
|
|
||||||
|
return local.X > (Center.X - halfW + MathUtil.Epsilon) &&
|
||||||
|
local.X < (Center.X + halfW - MathUtil.Epsilon) &&
|
||||||
|
local.Z > (Center.Z - halfH + MathUtil.Epsilon) &&
|
||||||
|
local.Z < (Center.Z + halfH - MathUtil.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector3[] GetCorners()
|
||||||
|
{
|
||||||
|
var halfW = Width * 0.5f;
|
||||||
|
var halfH = Height * 0.5f;
|
||||||
|
|
||||||
|
var p1 = new Vector3(Center.X - halfW, Center.Y, Center.Z - halfH);
|
||||||
|
var p2 = new Vector3(Center.X + halfW, Center.Y, Center.Z - halfH);
|
||||||
|
var p3 = new Vector3(Center.X + halfW, Center.Y, Center.Z + halfH);
|
||||||
|
var p4 = new Vector3(Center.X - halfW, Center.Y, Center.Z + halfH);
|
||||||
|
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
MathUtil.RotateAroundY(p1, Center, RotationDegrees),
|
||||||
|
MathUtil.RotateAroundY(p2, Center, RotationDegrees),
|
||||||
|
MathUtil.RotateAroundY(p3, Center, RotationDegrees),
|
||||||
|
MathUtil.RotateAroundY(p4, Center, RotationDegrees)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Geometry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 2D sector on XZ plane (origin, radius, angle span, facing direction).
|
||||||
|
/// All angle values are in degrees.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Sector : Shape
|
||||||
|
{
|
||||||
|
public Sector(Vector3 origin, float radius, float angleDegrees, float directionDegrees)
|
||||||
|
{
|
||||||
|
Origin = origin;
|
||||||
|
Radius = radius;
|
||||||
|
AngleDegrees = angleDegrees;
|
||||||
|
DirectionDegrees = directionDegrees;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ShapeType Type => ShapeType.Sector;
|
||||||
|
|
||||||
|
public Vector3 Origin { get; }
|
||||||
|
|
||||||
|
public float Radius { get; }
|
||||||
|
|
||||||
|
public float AngleDegrees { get; }
|
||||||
|
|
||||||
|
public float DirectionDegrees { get; }
|
||||||
|
|
||||||
|
public override Vector3 Center => Origin;
|
||||||
|
|
||||||
|
public override bool IsDegenerate()
|
||||||
|
{
|
||||||
|
return Radius <= MathUtil.Epsilon || AngleDegrees <= MathUtil.Epsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Contains(Vector3 point)
|
||||||
|
{
|
||||||
|
var distSq = MathUtil.DistanceXZSquared(Origin, point);
|
||||||
|
var radiusSq = Radius * Radius;
|
||||||
|
if (distSq >= (radiusSq - MathUtil.Epsilon))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dx = point.X - Origin.X;
|
||||||
|
var dz = point.Z - Origin.Z;
|
||||||
|
var angleToPoint = (float)(Math.Atan2(dz, dx) * 180f / Math.PI);
|
||||||
|
var delta = Math.Abs(MathUtil.DeltaAngleDegrees(DirectionDegrees, angleToPoint));
|
||||||
|
var half = AngleDegrees * 0.5f;
|
||||||
|
return delta < (half - MathUtil.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector3 GetLeftBoundaryPoint()
|
||||||
|
{
|
||||||
|
return PointAt(DirectionDegrees - (AngleDegrees * 0.5f), Radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector3 GetRightBoundaryPoint()
|
||||||
|
{
|
||||||
|
return PointAt(DirectionDegrees + (AngleDegrees * 0.5f), Radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vector3 PointAt(float degrees, float distance)
|
||||||
|
{
|
||||||
|
var rad = MathUtil.Deg2Rad(degrees);
|
||||||
|
var x = Origin.X + ((float)Math.Cos(rad) * distance);
|
||||||
|
var z = Origin.Z + ((float)Math.Sin(rad) * distance);
|
||||||
|
return new Vector3(x, Origin.Y, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
namespace Nightborn.Core.Geometry
|
||||||
|
{
|
||||||
|
public enum ShapeType
|
||||||
|
{
|
||||||
|
Circle = 1,
|
||||||
|
Rect = 2,
|
||||||
|
Sector = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class Shape
|
||||||
|
{
|
||||||
|
public abstract ShapeType Type { get; }
|
||||||
|
|
||||||
|
public abstract bool IsDegenerate();
|
||||||
|
|
||||||
|
public abstract Vector3 Center { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Geometry
|
||||||
|
{
|
||||||
|
public static class ShapeIntersection
|
||||||
|
{
|
||||||
|
public static bool Intersects(Shape a, Shape b)
|
||||||
|
{
|
||||||
|
if (a == null || b == null || a.IsDegenerate() || b.IsDegenerate())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is Circle ca && b is Circle cb)
|
||||||
|
{
|
||||||
|
return IntersectsCircleCircle(ca, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is Circle c && b is Rect r)
|
||||||
|
{
|
||||||
|
return IntersectsCircleRect(c, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is Rect r1 && b is Circle c1)
|
||||||
|
{
|
||||||
|
return IntersectsCircleRect(c1, r1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is Rect ra && b is Rect rb)
|
||||||
|
{
|
||||||
|
return IntersectsRectRect(ra, rb);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is Sector sa && b is Circle sc)
|
||||||
|
{
|
||||||
|
return IntersectsSectorCircle(sa, sc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is Circle cs && b is Sector sb)
|
||||||
|
{
|
||||||
|
return IntersectsSectorCircle(sb, cs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is Sector sx && b is Rect rx)
|
||||||
|
{
|
||||||
|
return IntersectsSectorRect(sx, rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is Rect ry && b is Sector sy)
|
||||||
|
{
|
||||||
|
return IntersectsSectorRect(sy, ry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is Sector s1 && b is Sector s2)
|
||||||
|
{
|
||||||
|
return s1.Contains(s2.Origin) || s2.Contains(s1.Origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IntersectsCircleCircle(Circle a, Circle b)
|
||||||
|
{
|
||||||
|
var distSq = MathUtil.DistanceXZSquared(a.Center, b.Center);
|
||||||
|
var radius = a.Radius + b.Radius;
|
||||||
|
return distSq < ((radius * radius) - MathUtil.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IntersectsCircleRect(Circle c, Rect r)
|
||||||
|
{
|
||||||
|
var localCenter = MathUtil.RotateAroundY(c.Center, r.Center, -r.RotationDegrees);
|
||||||
|
var halfW = r.Width * 0.5f;
|
||||||
|
var halfH = r.Height * 0.5f;
|
||||||
|
|
||||||
|
var nearestX = MathUtil.Clamp(localCenter.X, r.Center.X - halfW, r.Center.X + halfW);
|
||||||
|
var nearestZ = MathUtil.Clamp(localCenter.Z, r.Center.Z - halfH, r.Center.Z + halfH);
|
||||||
|
var nearest = new Vector3(nearestX, c.Center.Y, nearestZ);
|
||||||
|
|
||||||
|
var distSq = MathUtil.DistanceXZSquared(localCenter, nearest);
|
||||||
|
var radiusSq = c.Radius * c.Radius;
|
||||||
|
return distSq < (radiusSq - MathUtil.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IntersectsRectRect(Rect a, Rect b)
|
||||||
|
{
|
||||||
|
var cornersA = a.GetCorners();
|
||||||
|
var cornersB = b.GetCorners();
|
||||||
|
|
||||||
|
var axes = new[]
|
||||||
|
{
|
||||||
|
Normalize(cornersA[1] - cornersA[0]),
|
||||||
|
Normalize(cornersA[3] - cornersA[0]),
|
||||||
|
Normalize(cornersB[1] - cornersB[0]),
|
||||||
|
Normalize(cornersB[3] - cornersB[0])
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var axis in axes)
|
||||||
|
{
|
||||||
|
Project(cornersA, axis, out var minA, out var maxA);
|
||||||
|
Project(cornersB, axis, out var minB, out var maxB);
|
||||||
|
|
||||||
|
var overlap = Math.Min(maxA, maxB) - Math.Max(minA, minB);
|
||||||
|
if (overlap <= MathUtil.Epsilon)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IntersectsSectorCircle(Sector sector, Circle circle)
|
||||||
|
{
|
||||||
|
if (sector.Contains(circle.Center))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var distToOrigin = Vector3.Distance(sector.Origin, circle.Center);
|
||||||
|
if (distToOrigin < (circle.Radius - MathUtil.Epsilon))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var left = sector.GetLeftBoundaryPoint();
|
||||||
|
var right = sector.GetRightBoundaryPoint();
|
||||||
|
if (SegmentIntersectsCircle(sector.Origin, left, circle))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SegmentIntersectsCircle(sector.Origin, right, circle))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dx = circle.Center.X - sector.Origin.X;
|
||||||
|
var dz = circle.Center.Z - sector.Origin.Z;
|
||||||
|
var toCenter = (float)(Math.Atan2(dz, dx) * 180f / Math.PI);
|
||||||
|
var delta = MathUtil.DeltaAngleDegrees(sector.DirectionDegrees, toCenter);
|
||||||
|
var half = sector.AngleDegrees * 0.5f;
|
||||||
|
var clamped = MathUtil.Clamp(delta, -half, half);
|
||||||
|
var nearestArcPoint = sector.PointAt(sector.DirectionDegrees + clamped, sector.Radius);
|
||||||
|
return Vector3.Distance(nearestArcPoint, circle.Center) < (circle.Radius - MathUtil.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IntersectsSectorRect(Sector sector, Rect rect)
|
||||||
|
{
|
||||||
|
if (rect.Contains(sector.Origin))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var corners = rect.GetCorners();
|
||||||
|
foreach (var corner in corners)
|
||||||
|
{
|
||||||
|
if (sector.Contains(corner))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var left = sector.GetLeftBoundaryPoint();
|
||||||
|
var right = sector.GetRightBoundaryPoint();
|
||||||
|
|
||||||
|
for (var i = 0; i < corners.Length; i++)
|
||||||
|
{
|
||||||
|
var a = corners[i];
|
||||||
|
var b = corners[(i + 1) % corners.Length];
|
||||||
|
if (SegmentsIntersect2D(sector.Origin, left, a, b) ||
|
||||||
|
SegmentsIntersect2D(sector.Origin, right, a, b))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector3 Normalize(Vector3 v)
|
||||||
|
{
|
||||||
|
var len = (float)Math.Sqrt((v.X * v.X) + (v.Z * v.Z));
|
||||||
|
if (len <= MathUtil.Epsilon)
|
||||||
|
{
|
||||||
|
return new Vector3(0f, 0f, 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Vector3(v.X / len, 0f, v.Z / len);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Project(Vector3[] points, Vector3 axis, out float min, out float max)
|
||||||
|
{
|
||||||
|
min = Dot2D(points[0], axis);
|
||||||
|
max = min;
|
||||||
|
for (var i = 1; i < points.Length; i++)
|
||||||
|
{
|
||||||
|
var p = Dot2D(points[i], axis);
|
||||||
|
if (p < min)
|
||||||
|
{
|
||||||
|
min = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p > max)
|
||||||
|
{
|
||||||
|
max = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Dot2D(Vector3 a, Vector3 b)
|
||||||
|
{
|
||||||
|
return (a.X * b.X) + (a.Z * b.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SegmentIntersectsCircle(Vector3 p1, Vector3 p2, Circle circle)
|
||||||
|
{
|
||||||
|
var vx = p2.X - p1.X;
|
||||||
|
var vz = p2.Z - p1.Z;
|
||||||
|
var wx = circle.Center.X - p1.X;
|
||||||
|
var wz = circle.Center.Z - p1.Z;
|
||||||
|
var vv = (vx * vx) + (vz * vz);
|
||||||
|
if (vv <= MathUtil.Epsilon)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var t = ((wx * vx) + (wz * vz)) / vv;
|
||||||
|
t = MathUtil.Clamp(t, 0f, 1f);
|
||||||
|
var closest = new Vector3(p1.X + (vx * t), p1.Y, p1.Z + (vz * t));
|
||||||
|
var dSq = MathUtil.DistanceXZSquared(closest, circle.Center);
|
||||||
|
return dSq < ((circle.Radius * circle.Radius) - MathUtil.Epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SegmentsIntersect2D(Vector3 p1, Vector3 p2, Vector3 q1, Vector3 q2)
|
||||||
|
{
|
||||||
|
var o1 = Orientation(p1, p2, q1);
|
||||||
|
var o2 = Orientation(p1, p2, q2);
|
||||||
|
var o3 = Orientation(q1, q2, p1);
|
||||||
|
var o4 = Orientation(q1, q2, p2);
|
||||||
|
return (o1 * o2 < 0f) && (o3 * o4 < 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Orientation(Vector3 a, Vector3 b, Vector3 c)
|
||||||
|
{
|
||||||
|
return ((b.X - a.X) * (c.Z - a.Z)) - ((b.Z - a.Z) * (c.X - a.X));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Geometry
|
||||||
|
{
|
||||||
|
public struct Vector3
|
||||||
|
{
|
||||||
|
public Vector3(float x, float y, float z)
|
||||||
|
{
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
Z = z;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float X { get; }
|
||||||
|
public float Y { get; }
|
||||||
|
public float Z { get; }
|
||||||
|
|
||||||
|
public float Length => (float)Math.Sqrt((X * X) + (Y * Y) + (Z * Z));
|
||||||
|
|
||||||
|
public Vector3 Normalize()
|
||||||
|
{
|
||||||
|
var len = Length;
|
||||||
|
if (MathUtil.Approximately(len, 0f))
|
||||||
|
{
|
||||||
|
return new Vector3(0f, 0f, 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Vector3(X / len, Y / len, Z / len);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float Dot(Vector3 a, Vector3 b)
|
||||||
|
{
|
||||||
|
return (a.X * b.X) + (a.Y * b.Y) + (a.Z * b.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Vector3 Cross(Vector3 a, Vector3 b)
|
||||||
|
{
|
||||||
|
return new Vector3(
|
||||||
|
(a.Y * b.Z) - (a.Z * b.Y),
|
||||||
|
(a.Z * b.X) - (a.X * b.Z),
|
||||||
|
(a.X * b.Y) - (a.Y * b.X));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float Distance(Vector3 a, Vector3 b)
|
||||||
|
{
|
||||||
|
return (a - b).Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Vector3 operator +(Vector3 a, Vector3 b)
|
||||||
|
{
|
||||||
|
return new Vector3(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Vector3 operator -(Vector3 a, Vector3 b)
|
||||||
|
{
|
||||||
|
return new Vector3(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Vector3 operator *(Vector3 v, float scalar)
|
||||||
|
{
|
||||||
|
return new Vector3(v.X * scalar, v.Y * scalar, v.Z * scalar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Nightborn.Core.Combat;
|
||||||
|
using Nightborn.Core.Geometry;
|
||||||
|
|
||||||
|
namespace Nightborn.Core.Tests;
|
||||||
|
|
||||||
|
public class CombatLogicTests
|
||||||
|
{
|
||||||
|
private sealed class FixedRandomProvider : IRandomProvider
|
||||||
|
{
|
||||||
|
private readonly Queue<float> _values;
|
||||||
|
|
||||||
|
public FixedRandomProvider(params float[] values)
|
||||||
|
{
|
||||||
|
_values = new Queue<float>(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public float NextFloat()
|
||||||
|
{
|
||||||
|
if (_values.Count == 0)
|
||||||
|
{
|
||||||
|
return 0.99f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _values.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ResolveAttacks_HumanSectorHit_ShouldEmitHitWithHumanForm()
|
||||||
|
{
|
||||||
|
var random = new FixedRandomProvider(0.9f);
|
||||||
|
var sut = new CombatLogic(random);
|
||||||
|
var lastForm = FormType.Mist;
|
||||||
|
sut.OnHit += r => lastForm = r.SourceForm;
|
||||||
|
|
||||||
|
var attacks = new List<AttackData>
|
||||||
|
{
|
||||||
|
new AttackData
|
||||||
|
{
|
||||||
|
AttackId = 1,
|
||||||
|
Origin = new Vector3(0f, 0f, 0f),
|
||||||
|
HitShape = new Sector(new Vector3(0f, 0f, 0f), 3f, 90f, 0f),
|
||||||
|
SourceForm = FormType.Human,
|
||||||
|
BaseDamage = 10f,
|
||||||
|
CritChance = 0f,
|
||||||
|
CritMultiplier = 1.5f,
|
||||||
|
KnockbackForce = 2f
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var enemies = new List<EnemyState>
|
||||||
|
{
|
||||||
|
new EnemyState(100, 100f, 2f, new Circle(new Vector3(2f, 0f, 0f), 0.5f))
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = sut.ResolveAttacks(attacks, enemies, 1f / 60f);
|
||||||
|
|
||||||
|
Assert.That(results.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(lastForm, Is.EqualTo(FormType.Human));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ResolveAttacks_WolfRectOutOfRange_ShouldMiss()
|
||||||
|
{
|
||||||
|
var sut = new CombatLogic(new FixedRandomProvider(0.9f));
|
||||||
|
var attacks = new List<AttackData>
|
||||||
|
{
|
||||||
|
new AttackData
|
||||||
|
{
|
||||||
|
AttackId = 1,
|
||||||
|
HitShape = new Rect(new Vector3(0f, 0f, 0f), 1.5f, 5f, 0f),
|
||||||
|
SourceForm = FormType.Wolf,
|
||||||
|
BaseDamage = 20f,
|
||||||
|
CritChance = 0f,
|
||||||
|
CritMultiplier = 1.5f
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var enemies = new List<EnemyState>
|
||||||
|
{
|
||||||
|
new EnemyState(1, 100f, 1f, new Circle(new Vector3(6f, 0f, 0f), 0.4f))
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = sut.ResolveAttacks(attacks, enemies, 1f / 60f);
|
||||||
|
Assert.That(results, Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ResolveAttacks_MistCircle_ShouldHitAllEnemiesInRange()
|
||||||
|
{
|
||||||
|
var sut = new CombatLogic(new FixedRandomProvider(0.9f, 0.9f, 0.9f));
|
||||||
|
var attacks = new List<AttackData>
|
||||||
|
{
|
||||||
|
new AttackData
|
||||||
|
{
|
||||||
|
AttackId = 7,
|
||||||
|
HitShape = new Circle(new Vector3(0f, 0f, 0f), 5f),
|
||||||
|
SourceForm = FormType.Mist,
|
||||||
|
BaseDamage = 8f,
|
||||||
|
CritChance = 0f,
|
||||||
|
CritMultiplier = 1.5f
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var enemies = new List<EnemyState>
|
||||||
|
{
|
||||||
|
new EnemyState(1, 10f, 1f, new Circle(new Vector3(1f, 0f, 1f), 0.5f)),
|
||||||
|
new EnemyState(2, 10f, 1f, new Circle(new Vector3(2f, 0f, 2f), 0.5f)),
|
||||||
|
new EnemyState(3, 10f, 1f, new Circle(new Vector3(4f, 0f, 0f), 0.5f))
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = sut.ResolveAttacks(attacks, enemies, 1f / 60f);
|
||||||
|
Assert.That(results.Count, Is.EqualTo(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ResolveAttacks_CritChanceAboveOne_ShouldAlwaysCrit()
|
||||||
|
{
|
||||||
|
var sut = new CombatLogic(new FixedRandomProvider(0.5f));
|
||||||
|
var attacks = new List<AttackData>
|
||||||
|
{
|
||||||
|
new AttackData
|
||||||
|
{
|
||||||
|
AttackId = 1,
|
||||||
|
HitShape = new Circle(new Vector3(0f, 0f, 0f), 2f),
|
||||||
|
SourceForm = FormType.Mist,
|
||||||
|
BaseDamage = 10f,
|
||||||
|
CritChance = 1.2f,
|
||||||
|
CritMultiplier = 2f
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var enemies = new List<EnemyState>
|
||||||
|
{
|
||||||
|
new EnemyState(1, 100f, 1f, new Circle(new Vector3(0.5f, 0f, 0f), 0.5f))
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = sut.ResolveAttacks(attacks, enemies, 1f / 60f);
|
||||||
|
Assert.That(result[0].IsCritical, Is.True);
|
||||||
|
Assert.That(result[0].FinalDamage, Is.EqualTo(20f).Within(0.0001f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ResolveAttacks_HighDamageFirst_ShouldKillAndSkipLaterHits()
|
||||||
|
{
|
||||||
|
var sut = new CombatLogic(new FixedRandomProvider(0.9f, 0.9f));
|
||||||
|
var enemies = new List<EnemyState>
|
||||||
|
{
|
||||||
|
new EnemyState(1, 10f, 1f, new Circle(new Vector3(0f, 0f, 0f), 1f))
|
||||||
|
};
|
||||||
|
|
||||||
|
var attacks = new List<AttackData>
|
||||||
|
{
|
||||||
|
new AttackData
|
||||||
|
{
|
||||||
|
AttackId = 11,
|
||||||
|
HitShape = new Circle(new Vector3(0f, 0f, 0f), 2f),
|
||||||
|
SourceForm = FormType.Human,
|
||||||
|
BaseDamage = 8f,
|
||||||
|
CritChance = 0f,
|
||||||
|
CritMultiplier = 1.5f
|
||||||
|
},
|
||||||
|
new AttackData
|
||||||
|
{
|
||||||
|
AttackId = 12,
|
||||||
|
HitShape = new Circle(new Vector3(0f, 0f, 0f), 2f),
|
||||||
|
SourceForm = FormType.Wolf,
|
||||||
|
BaseDamage = 15f,
|
||||||
|
CritChance = 0f,
|
||||||
|
CritMultiplier = 1.5f
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = sut.ResolveAttacks(attacks, enemies, 1f / 60f);
|
||||||
|
Assert.That(results.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(results[0].AttackId, Is.EqualTo(12));
|
||||||
|
Assert.That(results[0].IsKillingBlow, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ResolveAttacks_DeltaTimeZero_ShouldReturnEmptyAndNoEvents()
|
||||||
|
{
|
||||||
|
var sut = new CombatLogic(new FixedRandomProvider(0.1f));
|
||||||
|
var eventCount = 0;
|
||||||
|
sut.OnHit += _ => eventCount++;
|
||||||
|
sut.OnKill += _ => eventCount++;
|
||||||
|
sut.OnCrit += _ => eventCount++;
|
||||||
|
sut.OnAttackResolved += (_, _) => eventCount++;
|
||||||
|
|
||||||
|
var results = sut.ResolveAttacks(
|
||||||
|
new List<AttackData>
|
||||||
|
{
|
||||||
|
new AttackData
|
||||||
|
{
|
||||||
|
AttackId = 1,
|
||||||
|
HitShape = new Circle(new Vector3(0f, 0f, 0f), 2f),
|
||||||
|
SourceForm = FormType.Mist,
|
||||||
|
BaseDamage = 10f,
|
||||||
|
CritChance = 0f,
|
||||||
|
CritMultiplier = 1.5f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new List<EnemyState> { new EnemyState(1, 10f, 1f, new Circle(new Vector3(0f, 0f, 0f), 0.5f)) },
|
||||||
|
0f);
|
||||||
|
|
||||||
|
Assert.That(results, Is.Empty);
|
||||||
|
Assert.That(eventCount, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestHit_TangentCircleCircle_ShouldBeMiss()
|
||||||
|
{
|
||||||
|
var a = new Circle(new Vector3(0f, 0f, 0f), 1f);
|
||||||
|
var b = new Circle(new Vector3(2f, 0f, 0f), 1f);
|
||||||
|
|
||||||
|
var hit = CombatLogic.TestHit(a, b);
|
||||||
|
Assert.That(hit, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ResolveAttacks_DuplicateTargetIdInList_ShouldDeduplicatePerAttack()
|
||||||
|
{
|
||||||
|
var sut = new CombatLogic(new FixedRandomProvider(0.9f));
|
||||||
|
var attack = new AttackData
|
||||||
|
{
|
||||||
|
AttackId = 1,
|
||||||
|
HitShape = new Circle(new Vector3(0f, 0f, 0f), 3f),
|
||||||
|
SourceForm = FormType.Mist,
|
||||||
|
BaseDamage = 5f,
|
||||||
|
CritChance = 0f,
|
||||||
|
CritMultiplier = 1.5f
|
||||||
|
};
|
||||||
|
|
||||||
|
var enemyA = new EnemyState(10, 20f, 1f, new Circle(new Vector3(0f, 0f, 0f), 1f));
|
||||||
|
var enemyB = new EnemyState(10, 20f, 1f, new Circle(new Vector3(0f, 0f, 0f), 1f));
|
||||||
|
var results = sut.ResolveAttacks(new List<AttackData> { attack }, new List<EnemyState> { enemyA, enemyB }, 1f / 60f);
|
||||||
|
|
||||||
|
Assert.That(results.Count, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CalculateKnockbackDistance_ShouldApplyCriticalMultiplier()
|
||||||
|
{
|
||||||
|
var normal = CombatLogic.CalculateKnockbackDistance(10f, 2f, false);
|
||||||
|
var critical = CombatLogic.CalculateKnockbackDistance(10f, 2f, true);
|
||||||
|
|
||||||
|
Assert.That(normal, Is.EqualTo(5f).Within(0.0001f));
|
||||||
|
Assert.That(critical, Is.EqualTo(7.5f).Within(0.0001f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ResolveAttacks_Miss_ShouldStillEmitAttackResolvedWithZero()
|
||||||
|
{
|
||||||
|
var sut = new CombatLogic(new FixedRandomProvider(0.9f));
|
||||||
|
var resolvedCount = -1;
|
||||||
|
sut.OnAttackResolved += (_, hitCount) => resolvedCount = hitCount;
|
||||||
|
|
||||||
|
var attacks = new List<AttackData>
|
||||||
|
{
|
||||||
|
new AttackData
|
||||||
|
{
|
||||||
|
AttackId = 2,
|
||||||
|
HitShape = new Circle(new Vector3(0f, 0f, 0f), 1f),
|
||||||
|
SourceForm = FormType.Mist,
|
||||||
|
BaseDamage = 5f,
|
||||||
|
CritChance = 0f,
|
||||||
|
CritMultiplier = 1.5f
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var enemies = new List<EnemyState>
|
||||||
|
{
|
||||||
|
new EnemyState(1, 10f, 1f, new Circle(new Vector3(10f, 0f, 0f), 0.5f))
|
||||||
|
};
|
||||||
|
|
||||||
|
_ = sut.ResolveAttacks(attacks, enemies, 1f / 60f);
|
||||||
|
Assert.That(resolvedCount, Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue