From 01f79dccfa0b4e0cc18f7599a3d3f771ef035537 Mon Sep 17 00:00:00 2001 From: SepComet <2428390463@qq.com> Date: Mon, 27 Apr 2026 17:47:10 +0800 Subject: [PATCH] feat(l0): implement combat logic core and geometry intersections --- src/Nightborn.Core/Combat/AttackData.cs | 17 ++ src/Nightborn.Core/Combat/CombatLogic.cs | 184 ++++++++++++ src/Nightborn.Core/Combat/DamageResult.cs | 16 + .../Combat/DefaultRandomProvider.cs | 14 + src/Nightborn.Core/Combat/EnemyState.cs | 25 ++ src/Nightborn.Core/Combat/FormType.cs | 9 + src/Nightborn.Core/Combat/IRandomProvider.cs | 7 + src/Nightborn.Core/Geometry/Circle.cs | 29 ++ src/Nightborn.Core/Geometry/MathUtil.cs | 93 ++++++ src/Nightborn.Core/Geometry/Rect.cs | 63 ++++ src/Nightborn.Core/Geometry/Sector.cs | 71 +++++ src/Nightborn.Core/Geometry/Shape.cs | 18 ++ .../Geometry/ShapeIntersection.cs | 248 ++++++++++++++++ src/Nightborn.Core/Geometry/Vector3.cs | 64 ++++ .../Nightborn.Core.Tests/CombatLogicTests.cs | 280 ++++++++++++++++++ 15 files changed, 1138 insertions(+) create mode 100644 src/Nightborn.Core/Combat/AttackData.cs create mode 100644 src/Nightborn.Core/Combat/CombatLogic.cs create mode 100644 src/Nightborn.Core/Combat/DamageResult.cs create mode 100644 src/Nightborn.Core/Combat/DefaultRandomProvider.cs create mode 100644 src/Nightborn.Core/Combat/EnemyState.cs create mode 100644 src/Nightborn.Core/Combat/FormType.cs create mode 100644 src/Nightborn.Core/Combat/IRandomProvider.cs create mode 100644 src/Nightborn.Core/Geometry/Circle.cs create mode 100644 src/Nightborn.Core/Geometry/MathUtil.cs create mode 100644 src/Nightborn.Core/Geometry/Rect.cs create mode 100644 src/Nightborn.Core/Geometry/Sector.cs create mode 100644 src/Nightborn.Core/Geometry/Shape.cs create mode 100644 src/Nightborn.Core/Geometry/ShapeIntersection.cs create mode 100644 src/Nightborn.Core/Geometry/Vector3.cs create mode 100644 tests/Nightborn.Core.Tests/CombatLogicTests.cs diff --git a/src/Nightborn.Core/Combat/AttackData.cs b/src/Nightborn.Core/Combat/AttackData.cs new file mode 100644 index 0000000..31bd84b --- /dev/null +++ b/src/Nightborn.Core/Combat/AttackData.cs @@ -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; } + } +} diff --git a/src/Nightborn.Core/Combat/CombatLogic.cs b/src/Nightborn.Core/Combat/CombatLogic.cs new file mode 100644 index 0000000..7966b88 --- /dev/null +++ b/src/Nightborn.Core/Combat/CombatLogic.cs @@ -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? OnHit; + public event Action? OnKill; + public event Action? OnCrit; + public event Action? 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 ResolveAttacks(IReadOnlyList attacks, IReadOnlyList enemies, float deltaTime) + { + var results = new List(); + if (deltaTime <= 0f || attacks == null || enemies == null || attacks.Count == 0 || enemies.Count == 0) + { + return results; + } + + var enemyById = new Dictionary(); + 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(); + var seenPairs = new HashSet(); + var hitCountByAttackKey = new Dictionary(); + var attackByKey = new Dictionary(); + + 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; + } + } +} diff --git a/src/Nightborn.Core/Combat/DamageResult.cs b/src/Nightborn.Core/Combat/DamageResult.cs new file mode 100644 index 0000000..84ccbe9 --- /dev/null +++ b/src/Nightborn.Core/Combat/DamageResult.cs @@ -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; } + } +} diff --git a/src/Nightborn.Core/Combat/DefaultRandomProvider.cs b/src/Nightborn.Core/Combat/DefaultRandomProvider.cs new file mode 100644 index 0000000..f3fae00 --- /dev/null +++ b/src/Nightborn.Core/Combat/DefaultRandomProvider.cs @@ -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(); + } + } +} diff --git a/src/Nightborn.Core/Combat/EnemyState.cs b/src/Nightborn.Core/Combat/EnemyState.cs new file mode 100644 index 0000000..bf67cb9 --- /dev/null +++ b/src/Nightborn.Core/Combat/EnemyState.cs @@ -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; + } +} diff --git a/src/Nightborn.Core/Combat/FormType.cs b/src/Nightborn.Core/Combat/FormType.cs new file mode 100644 index 0000000..fa8b39b --- /dev/null +++ b/src/Nightborn.Core/Combat/FormType.cs @@ -0,0 +1,9 @@ +namespace Nightborn.Core.Combat +{ + public enum FormType + { + Human = 1, + Wolf = 2, + Mist = 3 + } +} diff --git a/src/Nightborn.Core/Combat/IRandomProvider.cs b/src/Nightborn.Core/Combat/IRandomProvider.cs new file mode 100644 index 0000000..6cff702 --- /dev/null +++ b/src/Nightborn.Core/Combat/IRandomProvider.cs @@ -0,0 +1,7 @@ +namespace Nightborn.Core.Combat +{ + public interface IRandomProvider + { + float NextFloat(); + } +} diff --git a/src/Nightborn.Core/Geometry/Circle.cs b/src/Nightborn.Core/Geometry/Circle.cs new file mode 100644 index 0000000..9c28bd1 --- /dev/null +++ b/src/Nightborn.Core/Geometry/Circle.cs @@ -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; + } + } +} diff --git a/src/Nightborn.Core/Geometry/MathUtil.cs b/src/Nightborn.Core/Geometry/MathUtil.cs new file mode 100644 index 0000000..1e81ae8 --- /dev/null +++ b/src/Nightborn.Core/Geometry/MathUtil.cs @@ -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); + } + } +} diff --git a/src/Nightborn.Core/Geometry/Rect.cs b/src/Nightborn.Core/Geometry/Rect.cs new file mode 100644 index 0000000..4b2c537 --- /dev/null +++ b/src/Nightborn.Core/Geometry/Rect.cs @@ -0,0 +1,63 @@ +namespace Nightborn.Core.Geometry +{ + /// + /// 2D rectangle on XZ plane, rotated around Y axis. + /// Width = local X size, Height = local Z size. + /// + 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) + }; + } + } +} diff --git a/src/Nightborn.Core/Geometry/Sector.cs b/src/Nightborn.Core/Geometry/Sector.cs new file mode 100644 index 0000000..ea40982 --- /dev/null +++ b/src/Nightborn.Core/Geometry/Sector.cs @@ -0,0 +1,71 @@ +using System; + +namespace Nightborn.Core.Geometry +{ + /// + /// 2D sector on XZ plane (origin, radius, angle span, facing direction). + /// All angle values are in degrees. + /// + 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); + } + } +} diff --git a/src/Nightborn.Core/Geometry/Shape.cs b/src/Nightborn.Core/Geometry/Shape.cs new file mode 100644 index 0000000..9b47275 --- /dev/null +++ b/src/Nightborn.Core/Geometry/Shape.cs @@ -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; } + } +} diff --git a/src/Nightborn.Core/Geometry/ShapeIntersection.cs b/src/Nightborn.Core/Geometry/ShapeIntersection.cs new file mode 100644 index 0000000..31dbfc4 --- /dev/null +++ b/src/Nightborn.Core/Geometry/ShapeIntersection.cs @@ -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)); + } + } +} diff --git a/src/Nightborn.Core/Geometry/Vector3.cs b/src/Nightborn.Core/Geometry/Vector3.cs new file mode 100644 index 0000000..75c5009 --- /dev/null +++ b/src/Nightborn.Core/Geometry/Vector3.cs @@ -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); + } + } +} diff --git a/tests/Nightborn.Core.Tests/CombatLogicTests.cs b/tests/Nightborn.Core.Tests/CombatLogicTests.cs new file mode 100644 index 0000000..4a47486 --- /dev/null +++ b/tests/Nightborn.Core.Tests/CombatLogicTests.cs @@ -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 _values; + + public FixedRandomProvider(params float[] values) + { + _values = new Queue(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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + new EnemyState(1, 10f, 1f, new Circle(new Vector3(0f, 0f, 0f), 1f)) + }; + + var attacks = new List + { + 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 + { + 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 { 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 { attack }, new List { 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 + { + 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 + { + 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)); + } +}