背景 某些穿透型子弹弹道较粗,索敌又是以最近目标优先,因此经常“耿直”地锁定最近敌人,直接发送子弹,导致索敌看起来很呆,只命中很少的敌人。但是很多时候,目标周围有很多敌人,由于弹道较粗,只要稍微调整下发射角度就可以命中一堆敌人。
问题定义 穿透型宽弹道的子弹,可以看作一个矩形弹道,子弹起点 start (取 XZ 平面的二维坐标),初始方向 dir ,子弹射程 length ,子弹半宽 halfWidth ,给定主目标 targetEntity ,在保证主目标包含在这个矩形区域内,调整弹道角度,期望以覆盖更多敌人。
一开始会想针目标计算左右边界角度 [targetMinAngle, targetMaxAngle] ,然后以子弹起点为圆心,射程为半径进行怪物查询,查询后依次求得 [monsterMinAngle, monsterMaxAngle],但是这其中的关键点是角度,会频繁进行角度、三角函数、反三角函数的转换,这在定点数游戏里一方面性能开销较大,另一方面多次转换后误差也会叠加。
问题转换 首先为了简单起见,其他怪物的度量区间是以主目标方向为主的相对度量区间。考虑到对局中子弹只会向前发射,发射角度是半平面,也就是180°。希望在这个限制条件下,用三角函数的某个中间表达来表征角度区间,需要它能够和角度正相关,同时可以和正余弦方便进行转换。 tan(θ) 看起来是一个比较理想的表达,它在 [-π/2, π/2] 是单调递增的函数。但是在这个区间,它是无界函数,如果用它来表征区间,那么数值会极端发散,不利于后处理。那如果是 tan(θ/2) 呢?在给定的定义域情况下,它的值域是 [-1, 1] ,看起来非常的理想,此外我们还可以用半角公式方便地和 sin(θ) 、 cos(θ) 进行转换:
$$ sin(θ) = \cfrac{2tan(\cfrac{θ}{2})}{1+tan^2(\cfrac{θ}{2})} $$
$$ cos(θ) = \cfrac{1 - tan^2(\cfrac{θ}{2})}{1 + tan^2(\cfrac{θ}{2})} $$
$$ tan(\cfrac{θ}{2}) = \cfrac{sin(θ)}{1 + cos(θ)} = \cfrac{1 - cos(θ)}{sin(θ)} $$
因此,本问题的解决思路里最重要的一点是,将其他怪物形成的角度区间,改为基于主目标方向相对角度的 tan(θ) 区间。下文中所述的“区间”中的“值”,皆指代以主目标为基准的相对角度 θ 的半角正切 tan(θ/2)
实现 主目标最终允许偏转的区间
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static Fix FindBestParameter (... ){ var targetHalFixarameter = GetHalfAngleParameter(targetDistance, halfWidth); var targetMin = -targetHalFixarameter; var targetMax = targetHalFixarameter; } private static Fix GetHalfAngleParameter (Fix distance, Fix halfWidth ){ if (distance <= halfWidth) return Fix.One; var adjacent = FixMath.Sqrt(distance * distance - halfWidth * halfWidth); return halfWidth / (distance + adjacent); }
扫描候选区怪物
代码中先构建垂直三角形,其中有效半宽取弹道半宽和与怪物距离的最小值,意味着距离小于半宽的敌人,其所产生区间是整个半平面。
随后通过点积求得主目标方向的投影距离,而叉积则求得与主目标方向垂直的投影距离,注意叉积顺序,这里怪物在主目标方向左侧时,叉积结果是正的,反之为负。
上图中下排的左侧是计算瞄准怪物时,最大偏转区间所形成的三角函数;下排右侧是计算怪物方向与主目标方向夹角所形成的三句话。
随后通过三角函数和差角公式,将怪物区间偏移转换到主目标方向的局部坐标里,公式解释如上图。再通过 AddBoundaryParameter 调用半角公式求得对应角度的半角正切值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 private static void AddCandidateIntervals (... ){ if (distanceSqr <= Fix.Zero) { AddInterval(targetMin, targetMax); return ; } var distance = FixMath.Sqrt(distanceSqr); var effectiveWidth = FixMath.Min(halfWidth, distance); var adjacent = FixMath.Sqrt(FixMath.Max(distanceSqr - effectiveWidth * effectiveWidth, Fix.Zero)); var localForward = Fix2.Dot(baseDirection, offset); var localSide = Fix2.Cross(baseDirection, offset); var inverseDistanceSqr = Fix.One / distanceSqr; Span<Fix> points = stackalloc Fix[4 ]; points[0 ] = targetMin; points[1 ] = targetMax; var pointCount = 2 ; var minCos = (localForward * adjacent + localSide * effectiveWidth) * inverseDistanceSqr; var minSin = (localSide * adjacent - localForward * effectiveWidth) * inverseDistanceSqr; pointCount = AddBoundaryParameter(points, pointCount, minCos, minSin, targetMin, targetMax); var maxCos = (localForward * adjacent - localSide * effectiveWidth) * inverseDistanceSqr; var maxSin = (localSide * adjacent + localForward * effectiveWidth) * inverseDistanceSqr; pointCount = AddBoundaryParameter(points, pointCount, maxCos, maxSin, targetMin, targetMax); SortAndCompact(points, ref pointCount); Span<bool > segmentHits = stackalloc bool [3 ]; for (var i = 0 ; i + 1 < pointCount; i++) { var midpoint = (points[i] + points[i + 1 ]) * Fix.Half; segmentHits[i] = ContainsAtParameter(localForward, localSide, midpoint, halfWidth); } var inRun = false ; var runStart = Fix.Zero; for (var i = 0 ; i + 1 < pointCount; i++) { if (segmentHits[i]) { if (!inRun) { inRun = true ; runStart = points[i]; } } else if (inRun) { AddInterval(runStart, points[i]); inRun = false ; } } if (inRun) AddInterval(runStart, points[pointCount - 1 ]); } private static int AddBoundaryParameter (Span<Fix> points, int count, Fix cosine, Fix sine, Fix targetMin, Fix targetMax ){ if (cosine < Fix.Zero) return count; var parameter = sine / (Fix.One + cosine); if (parameter >= targetMin && parameter <= targetMax) points[count++] = parameter; return count; } private static bool ContainsAtParameter (Fix localForward, Fix localSide, Fix parameter, Fix halfWidth ){ var parameterSqr = parameter * parameter; var oneMinusParameterSqr = Fix.One - parameterSqr; var twiceParameter = 2 * parameter; var denominator = Fix.One + parameterSqr; var forwardNumerator = localForward * oneMinusParameterSqr + localSide * twiceParameter; var sideNumerator = FixMath.Abs(localSide * oneMinusParameterSqr - localForward * twiceParameter); return forwardNumerator >= Fix.Zero && sideNumerator <= halfWidth * denominator; }
上方代码中 ContainsAtParameter 是判断“使用某个半角参数 parameter 对应的方向发射时,当前怪物中心是否位于矩形弹道内。”,本质是向量二维旋转,但是由于坐标轴里X是左为正,所以和标准旋转矩阵还是有些差异的,具体推导如下图。
扫线求最优偏转 取区间中点作为稳定代表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 private static Fix ScanBestParameter (){ var activeCount = 0 ; var bestCount = -1 ; var bestParameter = Fix.Zero; for (var i = 0 ; i < _eventCache.Count;) { var parameter = _eventCache[i].Parameter; var starts = 0 ; var ends = 0 ; do { if (_eventCache[i].Delta > 0 ) starts++; else if (_eventCache[i].Delta < 0 ) ends++; i++; } while (i < _eventCache.Count && _eventCache[i].Parameter == parameter); activeCount += starts; if (parameter == Fix.Zero && IsBetter(activeCount, parameter, bestCount, bestParameter)) { bestCount = activeCount; bestParameter = parameter; } activeCount -= ends; if (i < _eventCache.Count && parameter < _eventCache[i].Parameter) { var interiorParameter = (parameter + _eventCache[i].Parameter) * Fix.Half; if (IsBetter(...)) { bestCount = activeCount; bestParameter = interiorParameter; } } } return bestParameter; }
还原最优命中偏转角 最后利用求得的 parameter 也就是半角正切值还原到正余弦,然后再利用标准的二维旋转矩阵,求得最终的发射方向。
1 2 3 4 5 6 7 8 9 private static Fix2 DirectionFromParameter (... ){ var parameterSqr = parameter * parameter; var inverse = Fix.One / (Fix.One + parameterSqr); var cosine = (Fix.One - parameterSqr) * inverse; var sine = 2 * parameter * inverse; return new Fix2(baseDirection.x * cosine - baseDirection.y * sine, baseDirection.x * sine + baseDirection.y * cosine).normalized; }