基于 CocosCreator v3.8.7 炮台旋转研究延伸,系统梳理方向向量的七大核心用途。


一、什么是方向向量

方向向量就是从A点指向B点的箭头,表示”从A出发,水平方向走多少、垂直方向走多少能到达B”。

1
2
dx = B.x - A.x
dy = B.y - A.y

示例

假设炮台在 (2, 3),敌人在 (5, 7)

1
2
3
4
5
6
7
7 ·  ·  ·  ·  · 敌人(5,7)
6 · · · · · ↑
5 · · · · · | dy = 4
4 · · · · · ↑
3 · 炮台(2,3)----→
1 2 3 4 5
dx = 3
1
2
3
dx = 5 - 2 = 3   (向右走 3 格)
dy = 7 - 3 = 4 (向上走 4 格)
方向向量 = (3, 4)

方向向量是游戏开发中最基础的数学工具,下面所有操作都从它出发。


二、七大核心应用

2.1 求角度(旋转/瞄准)

原理

atan2(dy, dx) 返回方向向量与X轴正方向的夹角(弧度),范围 (-π, π],即 (-180°, 180°]。

它和普通 atan(y/x) 的区别是:atan2 能根据 dxdy 的正负号判断象限,返回完整的 360 度范围角度,不会出现象限歧义。

计算过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
炮台在 (100, 100),敌人在 (200, 300)

第一步:求方向向量
dx = 200 - 100 = 100
dy = 300 - 100 = 200

第二步:求弧度
radian = atan2(200, 100) = 1.107 弧度

第三步:弧度转角度
angle = 1.107 × 180 / π ≈ 63.43°

第四步:赋值
tower.angle = 63.43° 炮台就对准敌人了

弧度与角度的换算公式:

1
2
3
4
angle = radian × 180 / π
radian = angle × π / 180

1 rad = 180° / π ≈ 57.2958°

四象限情况

1
2
3
4
5
6
7
8
9
        90°
|
| 敌人在右上方 → dx>0, dy>0 → 角度 0°~90°
|
180° ----炮台---- 0°
|
| 敌人在右下方 → dx>0, dy<0 → 角度 0°~-90°
|
-90°
敌人位置 dx dy atan2 结果
右上 (3, 4) +3 +4 53°
左上 (-3, 4) -3 +4 127°
左下 (-3, -4) -3 -4 -127°
右下 (3, -4) +3 -4 -53°

代码示例

1
2
3
4
5
6
7
update(deltaTime: number) {
const dx = this.enemy.position.x - this.tower.position.x
const dy = this.enemy.position.y - this.tower.position.y
const radian = Math.atan2(dy, dx)
const angle = (radian * 180) / Math.PI
this.tower.angle = angle
}

应用场景

  • 炮台瞄准敌人
  • 角色面朝鼠标方向
  • NPC 看向玩家
  • 箭头指向目标点

2.2 求距离(射程检测)

原理

勾股定理。方向向量的 dxdy 构成直角三角形的两条直角边,斜边就是两点之间的直线距离。

1
2
3
4
5
6
7
8
9
              敌人
/|
/ |
距离 / | dy
/ |
/ ____|
炮台 dx

distance = √(dx² + dy²)

计算过程

1
2
3
4
5
6
7
8
9
10
11
炮台在 (100, 100),敌人在 (400, 500)

第一步:求方向向量
dx = 400 - 100 = 300
dy = 500 - 100 = 400

第二步:求距离
distance = √(300² + 400²)
= √(90000 + 160000)
= √250000
= 500

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
update(dt: number) {
const dx = enemy.position.x - tower.position.x
const dy = enemy.position.y - tower.position.y
const distance = Math.sqrt(dx * dx + dy * dy)

if (distance < 300) {
// 进入射程,开始攻击
this.attack(enemy)
} else if (distance < 500) {
// 进入警戒范围,开始转向
this.lookAt(enemy)
} else {
// 太远了,无视
this.idle()
}
}

优化技巧:距离平方比较

Math.sqrt 开方运算比较慢,如果只是比较远近,可以跳过开方:

1
2
3
4
5
6
// 不需要开方,直接比较平方
const distSq = dx * dx + dy * dy

if (distSq < 300 * 300) { // 等价于 distance < 300
this.attack(enemy)
}

因为如果 a < b,那么 a² < b²(都是正数时),结果一样但省了一次开方。当场上有几百个敌人需要逐帧判断距离时,这个优化很有意义。

应用场景

  • 攻击范围检测
  • 拾取道具的距离判断
  • 技能释放范围
  • 仇恨系统(优先攻击最近的敌人)
  • 碰撞检测(两个圆形碰撞体距离 < 半径之和)

2.3 归一化(求单位向量)

原理

归一化就是保留方向、把长度缩放为 1

方向向量 (dx, dy) 同时包含两个信息:方向和距离。有时候我们只需要方向不需要距离,就要归一化。

1
2
原始向量:   (300, 400)  长度 = 500
归一化后: (0.6, 0.8) 长度 = 1

计算过程

1
2
3
4
5
6
7
8
9
10
方向向量 (dx, dy) = (300, 400)

第一步:求长度
length = √(300² + 400²) = 500

第二步:每个分量除以长度
nx = 300 / 500 = 0.6
ny = 400 / 500 = 0.8

归一化结果:(0.6, 0.8)

验证长度确实是 1:

1
√(0.6² + 0.8²) = √(0.36 + 0.64) = √1 = 1  ✓

为什么需要归一化

没有归一化的问题:

1
2
3
4
5
6
7
8
9
假设子弹速度 speed = 5

敌人A距离100:方向向量 (60, 80)
bullet.x += 60 * 5 = 300 每帧飞300!太快了

敌人B距离10:方向向量 (6, 8)
bullet.x += 6 * 5 = 30 每帧飞30,正常

同样的 speed,敌人越远子弹越快,这不合理

归一化后:

1
2
3
4
5
6
7
8
9
不管敌人多远,归一化后长度都是 1

敌人A归一化:(0.6, 0.8)
bullet.x += 0.6 * 5 = 3

敌人B归一化:(0.6, 0.8) 方向相同的话结果一样
bullet.x += 0.6 * 5 = 3

速度一致了!

代码示例

1
2
3
4
5
6
7
8
9
10
// 子弹匀速飞向敌人
const dx = enemy.position.x - bullet.position.x
const dy = enemy.position.y - bullet.position.y
const length = Math.sqrt(dx * dx + dy * dy)

const nx = dx / length
const ny = dy / length

bullet.position.x += nx * bulletSpeed * dt
bullet.position.y += ny * bulletSpeed * dt

应用场景

  • 子弹匀速飞行
  • 角色匀速移动到目标点
  • 力的方向(施加一个固定大小的力)
  • 法线方向(用于光照计算、反弹计算)

2.4 移动/追踪

原理

让一个物体朝另一个物体移动,每帧沿着方向向量走一小步。

1
2
3
4
第1帧:  敌人·············→玩家
第2帧: 敌人··········→玩家
第3帧: 敌人······→玩家
第4帧: 敌人··→玩家

计算过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
敌人在 (100, 100),玩家在 (400, 500),敌人速度 speed = 3

第一步:求方向向量
dx = 400 - 100 = 300
dy = 500 - 100 = 400

第二步:归一化
length = √(300² + 400²) = 500
nx = 300 / 500 = 0.6
ny = 400 / 500 = 0.8

第三步:按速度移动
enemy.x += 0.6 * 3 = 1.8
enemy.y += 0.8 * 3 = 2.4

敌人新位置:(101.8, 102.4)

下一帧重新算方向,继续追…

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
update(dt: number) {
const dx = player.position.x - enemy.position.x
const dy = player.position.y - enemy.position.y
const length = Math.sqrt(dx * dx + dy * dy)

// 到了就停下
if (length < 5) return

// 归一化
const nx = dx / length
const ny = dy / length

// 移动(乘以 dt 确保不同帧率下速度一致)
const speed = 200
enemy.position.x += nx * speed * dt
enemy.position.y += ny * speed * dt
}

直线追踪 vs 预判追踪

上面是最简单的直线追踪,敌人永远朝玩家当前位置跑。如果玩家在动,敌人会一直拐弯跟着。

更高级的做法是预判追踪:预测玩家会到哪,直接朝预测点飞,导弹就是这个原理。

1
2
3
4
5
6
7
8
9
10
直线追踪:弯弯曲曲跟着跑
~~~→
/
/ 玩家→→→→→→→
敌人

预判追踪:直接截击
敌人--------→ 交汇点

玩家→→→→→→→→↗

应用场景

  • 怪物追玩家
  • 导弹追踪目标
  • 宠物跟随主人
  • 自动寻路中的每一步
  • 摄像机平滑跟随角色

2.5 击退/反弹

原理

方向向量取反 (-dx, -dy) 就是反方向。击退就是把目标沿着反方向推出去。

1
2
攻击方向:   敌人 ----→ 玩家
击退方向: 玩家 ----→ (被推走)

计算过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
敌人在 (100, 100),玩家在 (400, 100),击退力 = 50

第一步:求从敌人到玩家的方向
dx = 400 - 100 = 300
dy = 100 - 100 = 0
方向向量:(300, 0)

第二步:归一化
length = 300
nx = 300 / 300 = 1
ny = 0 / 300 = 0
单位向量:(1, 0) → 水平向右

第三步:沿这个方向击退玩家
player.x += 1 * 50 = 50
player.y += 0 * 50 = 0

玩家从 (400, 100) 被推到 (450, 100)

带衰减的击退效果

实际游戏中击退不会一瞬间完成,而是快速推出然后逐渐减速:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 受击时记录击退速度
onHit(attacker: Node) {
const dx = this.node.position.x - attacker.position.x
const dy = this.node.position.y - attacker.position.y
const length = Math.sqrt(dx * dx + dy * dy)

// 初始击退速度
this.knockbackVx = (dx / length) * 500
this.knockbackVy = (dy / length) * 500
}

// 每帧衰减
update(dt: number) {
this.node.position.x += this.knockbackVx * dt
this.node.position.y += this.knockbackVy * dt

// 每帧衰减 5%
this.knockbackVx *= 0.95
this.knockbackVy *= 0.95
}

效果就是被打一下往后滑一段距离然后停下来。

反弹(弹球/弹幕)

球碰到墙壁时,反转撞墙方向的分量即可:

1
2
3
4
5
6
7
撞到左右墙壁 → vx = -vx  (水平反转)
撞到上下墙壁 → vy = -vy (垂直反转)

|
↗ | ↗
/ | /
球 ----→墙 → vx取反 → 球往回飞

应用场景

  • 被攻击时的击退效果
  • 弹球反弹
  • 爆炸冲击波(从爆炸中心向外推)
  • 弹幕反弹
  • 碰撞后的弹开效果

2.6 判断前后关系(点积)

原理

点积(Dot Product) 公式:

1
dot = ax * bx + ay * by

把两个向量的对应分量相乘再相加。结果的含义:

1
2
3
dot > 0  → 两个向量方向大致相同(夹角 < 90°)→ 在前方
dot = 0 → 两个向量互相垂直(夹角 = 90°) → 在正侧面
dot < 0 → 两个向量方向大致相反(夹角 > 90°)→ 在后方

直观理解

1
2
3
4
5
6
7
8
9
想象你站着面朝右方(朝向向量 = (1, 0))

B(在前方)

你→→→→→→ 朝向

C(在前方)

A(在后方)

你的朝向是 (1, 0),某个目标相对于你的方向是 (dx, dy)

1
2
3
4
dot = 1 * dx + 0 * dy = dx

如果 dx > 0 → 目标在你前面(和你朝向同一边)
如果 dx < 0 → 目标在你后面(和你朝向相反)

详细例子

1
2
3
4
5
6
7
8
9
10
11
12
13
玩家在 (200, 200),朝向右上方,朝向向量 face = (0.7, 0.7)

敌人A在 (300, 300) → 方向 = (100, 100) → 归一化 (0.7, 0.7)
dot = 0.7*0.7 + 0.7*0.7 = 0.49 + 0.49 = 0.98
结果 > 0 → 在前方 ✓(正前方)

敌人B在 (100, 100) → 方向 = (-100, -100) → 归一化 (-0.7, -0.7)
dot = 0.7*(-0.7) + 0.7*(-0.7) = -0.49 + (-0.49) = -0.98
结果 < 0 → 在后方 ✓(正后方)

敌人C在 (300, 100) → 方向 = (100, -100) → 归一化 (0.7, -0.7)
dot = 0.7*0.7 + 0.7*(-0.7) = 0.49 + (-0.49) = 0
结果 = 0 → 在正侧面 ✓

视野锥检测

点积还可以判断目标是否在一定角度的视野范围内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 视野半角60°,即前方120°扇形范围
const FOV_COS = Math.cos(60 * Math.PI / 180) // ≈ 0.5

update() {
const dx = enemy.x - player.x
const dy = enemy.y - player.y
const len = Math.sqrt(dx * dx + dy * dy)

// 归一化后求点积
const dot = faceDx * (dx / len) + faceDy * (dy / len)

if (dot > FOV_COS) {
// 在视野范围内,能看见
this.canSeeEnemy = true
} else {
// 在视野外
this.canSeeEnemy = false
}
}
1
2
3
4
5
6
7
8
          可见区域
/ 60° \
/ | \
/ | \
/ ____|____ \
玩家朝向方向

dot > 0.5 表示夹角 < 60°,在扇形内

应用场景

  • 视野/视锥检测(敌人能否看到玩家)
  • 背刺判定(攻击者在目标背后才触发)
  • 扇形技能范围
  • AI 感知系统
  • 判断是迎面碰撞还是追尾碰撞

2.7 判断左右关系(叉积)

原理

二维叉积(Cross Product) 公式:

1
cross = ax * by - ay * bx

结果的含义:

1
2
3
cross > 0  → b 在 a 的左边(逆时针方向)
cross = 0 → b 和 a 共线(平行)
cross < 0 → b 在 a 的右边(顺时针方向)

直观理解

1
2
3
4
5
6
7
8
9
想象你面朝右方(朝向 = (1, 0))

左边 (cross > 0)
B

你→→→→→→ 朝向

C
右边 (cross < 0)

详细例子

1
2
3
4
5
6
7
8
9
10
11
12
13
玩家朝向 face = (1, 0)(面朝右)

敌人A在上方:方向 = (1, 2)
cross = 1*2 - 0*1 = 2
结果 > 0 → 在左边 ✓(面朝右时,上方就是左边)

敌人B在下方:方向 = (1, -2)
cross = 1*(-2) - 0*1 = -2
结果 < 0 → 在右边 ✓(面朝右时,下方就是右边)

敌人C在正前方:方向 = (3, 0)
cross = 1*0 - 0*3 = 0
结果 = 0 → 共线 ✓(正前方,不偏左也不偏右)

实际应用:最短转向决策

炮台要追踪敌人,应该顺时针转还是逆时针转?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
update(dt: number) {
// 炮台当前朝向向量
const rad = tower.angle * Math.PI / 180
const faceDx = Math.cos(rad)
const faceDy = Math.sin(rad)

// 敌人方向
const dx = enemy.x - tower.x
const dy = enemy.y - tower.y

// 叉积判断转向
const cross = faceDx * dy - faceDy * dx
const rotateSpeed = 90 // 每秒转90度

if (cross > 0) {
// 敌人在左边,逆时针转(角度增大)
tower.angle += rotateSpeed * dt
} else if (cross < 0) {
// 敌人在右边,顺时针转(角度减小)
tower.angle -= rotateSpeed * dt
}
}
1
2
3
4
5
6
7
8
9
情况1:敌人在左边          情况2:敌人在右边

敌人
↑ ↓
| 逆时针转 顺时针转 |
| |
炮台→→朝向 炮台→→朝向
|
敌人

这比直接用 atan2 赋值的好处是:炮台有转速限制,更真实。不会一下子对准,而是慢慢转过去,叉积告诉它每帧该往哪边转。

点积 + 叉积组合使用

单独用一个只能得到部分信息,组合起来可以精确判断全方位:

1
2
3
4
5
6
7
8
9
10
11
      前左方              前右方
dot>0, cross>0 dot>0, cross<0
\ /
\ 前方 /
\ dot>0 /
正左方 -----+---→---+------ 正右方
cross>0 \ / cross<0
\ 后方/
dot<0
后左方 后右方
dot<0, cross>0 dot<0, cross<0
1
2
3
4
if (dot > 0 && cross > 0)  // → 前左方
if (dot > 0 && cross < 0) // → 前右方
if (dot < 0 && cross > 0) // → 后左方
if (dot < 0 && cross < 0) // → 后右方

应用场景

  • 转向决策(左转还是右转)
  • 环绕 AI(绕着玩家转圈的敌人)
  • 判断碰撞发生在左侧还是右侧
  • 路径规划中的拐弯方向
  • 格斗游戏中判断攻击从哪一侧来

三、炮台旋转中的角度环绕问题

问题描述

直接用 atan2 赋值角度没问题,但加上插值(lerp)做平滑旋转后,在角度越过临界值(180° / -180°)时,插值会走”远路”(反向旋转一大圈)。

1
2
3
4
当前角度 170°,目标角度 -170°

错误的插值路径:170° → 0° → -170° (转了 340°)
正确的插值路径:170° → 180° → -170° (只转了 20°)

解决方案

计算角度差,如果差值超过 180 度就 ±360 度修正,确保始终走最短路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
update(deltaTime: number) {
const dx = this.enemy.position.x - this.tower.position.x
const dy = this.enemy.position.y - this.tower.position.y
const radian = Math.atan2(dy, dx)
let angle = (radian * 180) / Math.PI

let currentAngle = this.tower.angle
let angleDiff = angle - currentAngle

// 处理角度环绕问题
if (angleDiff > 180) {
angle -= 360
} else if (angleDiff < -180) {
angle += 360
}

const interpolatedAngle = lerp(this.tower.angle, angle, deltaTime * 2)
this.tower.angle = interpolatedAngle
}

四、三维旋转方案:四元数

为什么不能在三维中用欧拉角插值

在三维空间中,如果分别对 X、Y、Z 三个角度单独计算差值再插值,旋转路径会变得复杂跳脱,不够平滑。还会出现万向节锁问题:当中间轴旋转 90 度时,另外两个轴会对齐,丧失一个自由度。

四元数方案

四元数(Quaternion)是一种用于表示三维旋转的高效数学工具,能避免万向节锁,插值更加平滑。

CocosCreator 中的 Rotation 就是四元数,使用球面线性插值(SLERP)实现平滑旋转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
update(deltaTime: number) {
// 求方向向量
const dV3 = this.enemy.getPosition().subtract(this.tower.getPosition())
// 归一化
const normalizedDir = dV3.normalize()
// 创建目标四元数(从 x 轴正方向旋转到目标方向)
const targetQuat = quat()
Quat.rotationTo(targetQuat, v3(1, 0, 0), normalizedDir)
// 获取当前旋转
const currentQuat = this.tower.getRotation()
// 球面线性插值
const slerpedQuat = quat()
Quat.slerp(slerpedQuat, currentQuat, targetQuat, deltaTime * 2)
// 应用旋转
this.tower.setRotation(slerpedQuat)
}

五、总结对比

工具 公式 得到什么 典型用途
方向向量 (dx, dy) 怎么走 一切的基础
atan2 atan2(dy, dx) 几度角 旋转瞄准
勾股定理 √(dx² + dy²) 多远 射程检测
归一化 (dx/len, dy/len) 纯方向 匀速移动
取反 (-dx, -dy) 反方向 击退反弹
点积 ax*bx + ay*by 前后关系 视野检测
叉积 ax*by - ay*bx 左右关系 转向决策
1
2
3
4
5
6
    方向向量 (dx, dy)
/ | \ \
atan2 距离 归一化 点积/叉积
| | | |
旋转 射程 匀速移动 前后左右
瞄准 检测 追踪击退 视野判定

这七个操作全都从最基础的方向向量 (dx, dy) 出发,掌握了它们基本就能处理游戏开发中绝大多数与”方向、位置、运动”相关的问题。