feat(l0): implement combat logic core and geometry intersections

This commit is contained in:
SepComet 2026-04-27 17:47:10 +08:00
parent 227c3878ae
commit 01f79dccfa
15 changed files with 1138 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -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();
}
}
}

View File

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

View File

@ -0,0 +1,9 @@
namespace Nightborn.Core.Combat
{
public enum FormType
{
Human = 1,
Wolf = 2,
Mist = 3
}
}

View File

@ -0,0 +1,7 @@
namespace Nightborn.Core.Combat
{
public interface IRandomProvider
{
float NextFloat();
}
}

View File

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

View File

@ -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);
}
}
}

View File

@ -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)
};
}
}
}

View File

@ -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);
}
}
}

View File

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

View File

@ -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));
}
}
}

View File

@ -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);
}
}
}

View File

@ -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));
}
}