前言
最近需要做鼠标选择单位的功能,所以给大家分享一下我是如何实现的,简单来说,是在鼠标点击后,通过我写的获取单位点集的方法,先通过AABB包围盒进行粗筛,然后再通过射线法进行精确判定,最后根据Y轴的大小得到选中的单位,下面我给大家分享一下我的实现逻辑
效果展示

代码实现
鼠标点击
这里本来没有什么好说的,但是给大家分享一个普遍可能会遇到的问题,就是鼠标点击UI,因为比如我直接用的Input.IsActionJustPressed()方法做点击,这回导致当我们点击UI的时候也会去做点击单位,解决办法如下:
cs
//当点击UI时直接返回
if (gameManager.GetViewport().GuiGetHoveredControl() != null)
{
return;
}
这样,我们只需要注意把不会遮挡的UI,比如占满全屏的Control节点的的Mouse-Filter 设置为Ignore就好
AABB包围盒
介绍
AABB(Axis-Aligned Bounding Box)的全称是轴对齐包围盒。简单来说,就是找一个最小的矩形,能把你的多边形完全框住,我们先用他进行粗筛,否则如果每次都要对所有单位做精确的多边形判定,CPU会直接原地爆炸
包围盒计算
首先注意,我们要提前设置好单位的点集,因为之后的射线法判断,所以需要按顺序设置,这里我用的是List存的点集,用Rect2存的AABB包围盒,接下来我们需要计算包围盒,注意计算好的包围盒需要跟着单位作为常量
cs
public Rect2 CalulateAABB(List pointSet)
{
float minX = pointSet[0].X;
float maxX = pointSet[0].X;
float minY = pointSet[0].Y;
float maxY = pointSet[0].Y;
for (int i = 0; i < pointSet.Count; i++)
{
Vector2 p = pointSet[i];
if (p.X < minX) minX = p.X;
else if (p.X > maxX) maxX = p.X;
if (p.Y < minY) minY = p.Y;
else if (p.Y > maxY) maxY = p.Y;
}
return new Rect2(new Vector2(minX, minY), new Vector2(maxX - minX, maxY - minY));
}
这里的逻辑就是:不断更新四个边界值,最后用左下角坐标和宽高构造出矩形,因为我们是用的Rect2,所以注意用的两个点分别是position和size
使用AABB进行粗筛
cs
// 先通过AABB包围盒粗筛
foreach (var unit in unitPointSet)
{
if (unit.AABB.HasPoint(mousePos - unit.nowPos))
{
// 通过射线法判断是否在多边形内
if (IsPointInPolygon(mousePos - unit.nowPos, unit.pointSet))
tmpUnit.Add(unit);
}
}
这里有个细节:mousePos - unit.nowPos是把鼠标坐标转换到单位的局部坐标系。因为我们存储的点集都是相对于单位中心(0,0)的,所以需要先做这个转换
射线法精确判定
通过了AABB粗筛之后,就要进入真正的重头戏了:判断点是否在多边形内部
射线法的核心思想
想象你站在某个位置,往右边射出一根无限长的射线。这根射线会和多边形的边界相交。如果交点数量是奇数,说明你在多边形内部;如果是偶数(包括0),说明你在外部
我们可以把它想象成"翻墙"的过程:
- 你在外面,翻过1道墙,就进去了(奇数)
- 再翻1道墙,就出来了(偶数)
- 再翻1道墙,又进去了(奇数)
所以,统计撞击次数的奇偶性,就能判断最终位置是在内还是外
代码实现
cs
//射线法,连接点(顺序),判断碰撞为奇数在多边形内
private bool IsPointInPolygon(Vector2 mouse, List pointSet)
{
bool inside = false;
for (int i = 0, j = pointSet.Count - 1; i < pointSet.Count; j = i++)
{
if ((pointSet[i].Y > mouse.Y) != (pointSet[j].Y > mouse.Y) &&
mouse.X < (pointSet[j].X - pointSet[i].X) * (mouse.Y - pointSet[i].Y) / (pointSet[j].Y - pointSet[i].Y) + pointSet[i].X)
inside = !inside;
}
return inside;
}
这段代码非常精简,接下来我给大家拆开讲解一下
1) 双指针
首先看循环头部:for (int i = 0, j = pointSet.Count - 1; i < pointSet.Count; j = i++)
这里为什么要用两个指针,而不是直接用(i + 1) % pointSet.Count?
原因如下:
-
性能考虑: 模除运算在底层涉及除法,比简单的赋值和自增要慢。在需要对成百上千个单位进行实时判定的游戏中,这种细微差别会累积成可观的性能开销
-
逻辑清晰 :
j = i++这种"指针尾随"的写法是图形学算法的标准写法,一开始就处理封口边,循环体内部非常纯净
2) 坐标计算
- 高度判断
cs
(pointSet[i].Y > mouse.Y) != (pointSet[j].Y > mouse.Y)
这一行是在判断:这条边的两个端点,一个在鼠标上方,一个在鼠标下方
如果两个点都在鼠标上方,或者都在下方,那这条边根本不会和水平射线相交,直接跳过就行
- 计算交点X坐标
这是整个算法最核心的数学部分:
cs
mouse.X < (pointSet[j].X - pointSet[i].X) * (mouse.Y - pointSet[i].Y) / (pointSet[j].Y - pointSet[i].Y) + pointSet[i].X
我们把它看作**"相似三角形"或者"线性插值比例"**,就很容易理解了 :
-
先算垂直进度比例:(mouse.Y - p1.Y) / (p2.Y - p1.Y)。这就是算出当前鼠标的高度,占这条线段总高度的百分之几 。
-
应用到水平方向:因为点在同一条斜线上,你垂直方向爬了 50%,水平方向也一定移动了总宽度的 50% 。所以用线段总宽度 (p2.X - p1.X) 乘以这个比例,算出 X 轴上的偏移量。
-
坐标还原:最后加上起点的 p1.X,就是地图上的真实绝对坐标了 。
对应的数学公式表现为:
hitX=ΔX×mouseOffsetΔY+XstarthitX = \Delta X \times \frac{\text{mouseOffset}}{\Delta Y} + X_{\text{start}}hitX=ΔX×ΔYmouseOffset+Xstart

完整判断代码
cs
//注意我这里返回的是我单位的唯一Id,-1就是空,大家根据自己的需要修改
public long CalculateChooseUnit(Vector2 mousePos, List<(long id, List<Vector2> pointSet, Rect2 AABB, Vector2 nowPos)> unitPointSet)
{
List <(pointSet, Rect2 AABB, Vector2 nowPos)> tmpUnit = new();
// 先通过AABB包围盒粗筛
foreach (var unit in unitPointSet)
{
if (unit.AABB.HasPoint(mousePos - unit.nowPos))
{
// 通过射线法判断是否在多边形内
if (IsPointInPolygon(mousePos - unit.nowPos, unit.pointSet))
tmpUnit.Add(unit);
}
}
if (tmpUnit.Count == 0)
return (-1);
// 如果有多个单位重叠,选择Y坐标最大的(最下面的)
var maxY = tmpUnit[0];
for (int i = 1; i < tmpUnit.Count; i++)
{
if (tmpUnit[i].nowPos.Y > maxY.nowPos.Y)
maxY = tmpUnit[i];
}
return (maxY.id);
}
结尾
以上就是我实现鼠标选择,检测碰撞的思路。AABB粗筛保证性能,射线法保证准确性,两者结合能处理各种复杂形状,感谢大家观看