第3章 角度,方向以及旋转

本章内容:上一节课,我们学习了如何让物体(游戏对象)动起来。但是这种移动是“平移”,物体沿着一条直线移动,而直线的角度是固定不变的。而本次课我们将学到如何让物体按任意角度移动。

角度,方向以及旋转

对于平移而言,许多游戏都可以胜任了,比如超级玛丽中的马里奥人物移动,跳跃。实际上马里奥这个游戏对象并不涉及“角度”这个属性,虽然看起来它会按不同的角度跳跃。这是因为,有关它移动的方向都是固定的,只是大小不同,重力永远向下,而跳跃永远向上,移动也只有指向左和右。但是超级玛丽中,有一个游戏对象是必须引入角度这个参数,那就是火花吐出的火球(子弹),因为火球的移动速度是固定的,而方向是在发出时对准游戏角色的。其他例子比如俯视游戏角色移动(俯视赛车),几乎所有可以瞄准的子弹等等,换句话么,任何可以转向的游戏对象都需要引入角度这个属性。

角度、弧度初步

一旦提到角度了,我们不得不说数学中的三角函数了。因为他们就是解决边角关系的工具。我们这里主要解决的是各种角和坐标轴之间的关系。
我们在love引擎中用到的角度一般以弧度制为准,即180度相当于弧度中的Pi(3.14…),你可以使用math.rad 和math.deg进行相互转化。
一般而言,角度实际上指的是一个物体的方向和y轴负方向之间的角度差异。旋转为0时,方向指向y轴负方向,同理旋转为Pi/2时,也就是90度时,指的是x轴正方向。
角度对于我们游戏有什么帮助呢?我们如何使用它呢?我们用个简单的例子。
首先建立一个简单的游戏对象,我们之前学过的:

1
2
3
4
5
6
local rect = {
x = 100,
y = 100,
image = love.graphics.newImage("test.png")
rot = 0
}

这里要讲解两点,首先,对于lua来讲,尽量使用局部变量来减少你变量的影响范围,能够提升效率的同时,方便debug,因为很很可能不知何时在某个函数中就对一个全局变量不小心赋值(覆盖)了。
第二,我们游戏对象多了一个rot属性,实际上就是旋转–rotation的缩写,看老外的代码你很可能用上这些英文和缩写,所以自己先用上吧。

然后,我们希望让球转起来,同移动类似,就是每帧或者每秒对rot进行修改。所以在update中有如下代码

1
rect.rot = rect.rot + 0.2

记住,你的rot单位是弧度,每帧如果变化正好是2*Pi(6.28)你是看不出它旋转的,因为正好转了一整圈,所以要习惯弧度的尺度。
然后,绘制这个图片

1
love.graphics.draw(rect.image,rect.x,rect.y,rect.rot,1,1,rect.image:getWidth()/2,rect.image:getHeight()/2)

这里我们按draw的参数填入相应的数据,注意我们是如何得到一个图片的锚点中心的。最好的做法是先把这两个值得到,而不必没帧都去计算。这是你需要一直保持注意的习惯。

1
2
3
4
5
6
--in load
rect.ox = rect.image:getWidth()/2
rect.oy = rect.image:getHeight()/2
--in draw
love.graphics.draw(rect.image,rect.x,rect.y,rect.rot,1,1,rect.ox,rect.oy)

完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
local rect
function love.load()
rect = {
x = 100,
y = 100,
image = love.graphics.newImage("test.png")
rot = 0
}
rect.ox = rect.image:getWidth()/2
rect.oy = rect.image:getHeight()/2
end
function love.update()
rect.rot = rect.rot + 0.2
end
function love.draw()
love.graphics.draw(rect.image,rect.x,rect.y,rect.rot,1,1,rect.ox,rect.oy)
end

我们会看到这个图片开始旋转啦。

计算某个点绕另一个点旋转后的坐标

如果。。。我们来画一条线,让它以线段的一段为轴旋转,应该如何做呢?
画线函数很简单,只需要两个端点的位置就行,为轴的端点是不动的,而我们要求的是另一端,而已知的是线段长。
首先我们建立一个游戏对象。

1
2
3
4
5
6
7
8
line = {
ox = 0,
oy = 0,
len = 100 --(lenth)
rot = 0,
tx = 0,
ty = -100
}

很容易看出,一端在原点0,0 长100,旋转为0的线段的另一端的坐标为0,-100。是如何计算的,其他旋转如何呢?

1
2
line.tx = math.sin(line.rot)*line.len
line.ty = -math.cos(line.rot)*line.len

这是个很容易的三角函数题,将端点T(相对于端点O)向两条坐标轴连辅助线,找到直角三角形,然后就找到函数关系了。

但是如果是端点O不在坐标原点该如何呢?有两种思想解决。
一个是算数计算,连接O点到两个坐标轴,其延长线与T点的辅助线的交点会形成一个直角三角形,然后计算。
另一个思想是移动坐标轴,先按o点位于0,0将t点的位置计算出来。然后把整个坐标系移动到o点的实际位置,也就是t点也要按移动规则移动,然后计算。无论那种,最后都会得出下面的代码

1
2
line.tx = line.ox + math.sin(line.rot)*line.len
line.ty = line.oy - math.cos(line.rot)*line.len

速度的分解

实际上上面的代码也适合速度分解,设想一个小车的速度为100,他的角度是Pi/6,那么它在x,y轴的速度分量分别是多少呢?

1
2
3
4
car.speed = 100
car.rot = math.pi/6
car.vx = math.sin(car.rot)*car.speed
car.vy = -math.cos(car.rot)*car.speed

注意有个负号,因为y轴是倒置的。

计算两个点之间的旋转

比如,有一个坦克,坦克车辆的旋转由键盘控制,坦克的炮塔旋转由鼠标控制,那么,我们已知坦克的中心坐标和鼠标坐标,需要计算坦克的旋转了。实际上,整个过程就是上面过程的逆过程。用arc-tan(反正切)来计算结果。
同样两种思想,这里不再赘述。得出一下代码

1
local rot = math.atan((line.tx-line.ox)/(line.ty/line.oy))

这里值得注意的是,这里得出的rot的取值范围在0,2*Pi之间,有时,我们希望的取值范围可能是-Pi,Pi,需要自行进行调整。
另外,在计算的时候,移动要很清楚哪个点到哪个点的角度,弄反的话会有半圈大小的差异。
还有一点,如果把一个物体当前的角度旋转到目标角度,我们可能希望转的是过程更短的半圈,所以要进行判断,而判断的过程就涉及旋转的加减,一定要注意符号以及旋转的周期性。具体情形,见后文案例。

向量以及世界坐标系

上文介绍了以纯三角函数的方式来解出相应的角度问题,这种方法来的比较直接,不过并不是很简单。实际上,我们在数学中还学过向量,而向量就可以很好的解决我们上文提到的旋转和角度问题。

这里可以使用hump.vector-light库来解决问题,(顺带提一句,hump.vector和vector-light拥有同样的方法,只是前者是基于对象的,而后者只是方法。基于对象的在大量使用过程中会产生一些不必要的垃圾,不过影响不大)。关于向量本身的数学问题,这里暂时不多介绍,而方法在下面文档中记载的比较详细,如果有必要,我们后面会单独开设一个主题。http://hump.readthedocs.io/en/latest/vector-light.html

另外,还有一种对于绘制来讲的旋转方法。因为矢量绘图并不提供图像旋转,因此,想要旋转一个矩形,比较麻烦,需要首先矩形转换为多边形,然后就每一个顶点以旋转中心进行坐标旋转,然后再绘制。不过使用love提供的绘图坐标系系统就很容易来解决。

1
2
3
4
5
6
7
8
9
10
11
12
--使用绝对坐标计算法来旋转
local rect = {0,0,200,100} --x,y,w,h
local polygon = {0,0,200,0,200,100,0,100} --转换为四个顶点
local verts= 坐标旋转(polygon,math.pi/3,100,50) --pi/3 为旋转弧度,100,50为旋转中心
love.graphics.polygon("line",verts) --绘制多边形
--使用坐标系法旋转
love.graphics.push()
love.graphics.translate(100,50)
love.graphics.rotate(math.pi/3)
love.graphics.rectangle(0,0,200,100)
love.graphics.pop()

很明显,使用坐标系旋转可以避免很多计算问题,不过这种方法有可能导致一些库不兼容(比如一些摄像机,一些canvas变换等等)。关于坐标系部分,我们将在摄像机相关章节进一步讲解。
另外,还有一种方法是,把矢量图形转化为mesh或canvas等作为绘制对象来处理。这个属于较为高级的内容,这里不再详细讲解。

love坐标体系及摄像机

既然提到了坐标系,我们就具体讲解一下love的坐标系和摄像机原理。
我们提及过屏幕坐标系和游戏坐标系。屏幕坐标系是物理硬件的,我们看的实际屏幕的坐标系。以屏幕左上角为原点,右下方为正方向。而我们还有一个游戏坐标系。它是你所有绘图api的坐标系。love坐标系相关api或者集成成一个摄像机对象,就是告诉引擎,我们游戏坐标系和屏幕坐标系的相对位置关系的。
上面讲起来有点复杂,举个例子。你在手机上方放一块玻璃板。此时,你的手机屏幕就是屏幕坐标系,而玻璃板上的坐标系(比如你在玻璃板正中间画一个平面直角系)就是游戏坐标系。然后你把玻璃板的0,0位置(中间,刚刚你自己画的),和屏幕的左上角对其。此时就是我们平时游戏的初始状态。
现在,我们就可以用love.graphics.translate/rotate/scale等对这个玻璃板进行控制了。而实际显示在屏幕上的,是你移动玻璃板后在屏幕映出的玻璃板上绘制的东西。
love.graphics.push/pop/origin实际也可以很形象的说明。push的意义就是拿另一块玻璃板,跟当前玻璃板的位置对其。而pop就是扔掉最上层的玻璃板。origin就是让玻璃板跟手机左上角对齐。当然,上面控制玻璃板移动的方法,实际只能控制最上层的。
另一个形象的比喻是,两种机械臂来安装集成电路。一种是手臂不动,电路板移动(对应坐标系移动绘图)。另一种是手臂动,而电路板不动(对应实际坐标绘图)。下面举个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
local camera = {
x = 0,
y = 0,
rotation = 0
scale = 0
}
function camera:draw(func)
love.graphics.push()
love.graphics.translate(self.x,self.y)
love.graphics.rotate(self.rotation)
love.graphics.scale(self.scale)
func()
love.graphics.pop()
end
camera.x,camera.y,camera.rotation = 100,100,1
camera:draw(function()
love.graphics.box("fill",0,0,100,30)
end)

如果你把这个层叠的draw函数展开,你会发现它跟我们上面坐标系法是一样的。
另外一点是,有的摄像机习惯的把屏幕的中心作为摄像机的原点。也就是你画0,0的一个圆,在屏幕上实际上是在屏幕中心的400,300(默认)。
常用的摄像机库比如hump.camera,gamara等等。有的库还内置了镜头跟随,震屏,镜头限制等效果。

上面讲解了一些关于角度和旋转的内容,实际上很多东西在初中数学都讲过,之所以这么详细的讲解,是希望大家能够通过这样的讲解有能力自己把一些数学问题转化为编程思想。下面进行我们本次课的实践。

设计阶段

这次我们希望做一个更复杂的东西,一个坦克,这个坦克能够使用键盘的w,s来前进和倒退,a,d来进行转向。然后还有一个炮塔,炮塔的方向始终指向鼠标方向。坦克的速度是个固定值。
wow是不是很酷,感觉很快很快,我们就拥有一个俯视的坦克游戏了。别着急,我们先从基础的开始。

  1. keydown w,s按坦克当前方向前进和后退
  2. keydown a,d改变坦克方向
  3. 每帧取鼠标位置,并设置为目标点target
  4. 暂时我们使用移动的第一种情形,就是匀速运动。
  5. 绘制方面,坦克是一个长100,宽30的矩形,炮塔是一个半径10的圆形,大炮是一个长50,宽10的矩形。

ok 设计阶段结束,我们来用代码一个一个实现他们的功能。

实现阶段

首先设计一个游戏对象,叫做tank和一个游戏对象target

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
local tank = {
x = 400, --放到屏幕中心
y = 300,
w = 30,
h = 100,
speed = 1,
rot = 0,
cannon = {
w = 10,
h = 50,
radius = 10
}
}
local target = {
x = 0,
y = 0
}

然后,我们添加按键控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function keyControl()
local down = love.keyboard.isDown --方便书写,而且会加快一些速度
if down("a") then
tank.rot = tank.rot - 0.1
elseif down("d") then
tank.rot = tank.rot + 0.1
elseif down("w") then
tank.x = tank.x + tank.speed*math.sin(tank.rot) --速度直接叠加,就不加入vx变量了
tank.y = tank.y - tank.speed*math.cos(tank.rot)
elseif down("s") then
tank.x = tank.x - tank.speed*math.sin(tank.rot) --倒车
tank.y = tank.y + tank.speed*math.cos(tank.rot)
end
end

然后是鼠标控制

1
2
3
4
5
6
7
8
9
10
11
12
13
function getRot(x1,y1,x2,y2)
if x1==x2 and y1==y2 then return 0 end
local angle= - math.atan((x2-x1)/(y2-y1))
if y1-y2<0 then angle=angle+math.pi end
if angle<0 then angle=angle+2*math.pi end
return angle
end
function mouseControl()
target.x,target.y = love.mouse.getPosition()
local rot = getRot(tank.x,tank.y,target.x,target.y)
tank.cannon.rot = rot --大炮的角度为坦克与鼠标连线的角度
end

最后是绘图,这里采用的是坐标系法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
--车身
love.graphics.push()
love.graphics.translate(tank.x,tank.y)
love.graphics.rotate(tank.rot)
love.graphics.setColor(128,128,128)
love.graphics.rectangle("fill",-15,-50,30,100) --以0,0为中心
love.graphics.pop()
--炮塔
love.graphics.push()
love.graphics.translate(tank.x,tank.y)
love.graphics.rotate(tank.cannon.rot)
love.graphics.setColor(0,255,0)
love.graphics.circle("fill",0,0,tank.cannon.radius)
love.graphics.setColor(0,255,255)
love.graphics.rectangle("fill",0,0,tank.cannon.w,tank.cannon.h)
love.graphics.pop()
--激光
love.graphics.setColor(255,0,0)
love.graphics.line(tank.x,tank.y,target.x,target.y)

作业:

  1. 尝试使用顶点计算法来绘制坦克。
  2. 尝试对坦克移动边界进行限制。(上次课讲过的)
  3. 尝试使用鼠标点击的方法来控制坦克。点击后,坦克转为鼠标方向,并移动至鼠标位置。
  4. 尝试“抵达”判断,如何判断抵达? 简单的做法就是单位到目标的距离不再减少。两点距离公式自己百度。
  5. 仿造上面的方法做一款小飞机吧,方法类似,可以发挥你的想象力来制作出自己的东西。

本课代码

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
function love.load()
tank = {
x = 400, --放到屏幕中心
y = 300,
w = 60,
h = 100,
speed = 1,
rot = 0,
cannon = {
w = 10,
h = 50,
radius = 20
}
}
target = {
x = 0,
y = 0
}
end
function keyControl()
local down = love.keyboard.isDown --方便书写,而且会加快一些速度
if down("a") then
tank.rot = tank.rot - 0.1
elseif down("d") then
tank.rot = tank.rot + 0.1
elseif down("w") then
tank.x = tank.x + tank.speed*math.sin(tank.rot) --速度直接叠加,就不加入vx变量了
tank.y = tank.y - tank.speed*math.cos(tank.rot)
elseif down("s") then
tank.x = tank.x - tank.speed*math.sin(tank.rot) --倒车
tank.y = tank.y + tank.speed*math.cos(tank.rot)
end
end
function getRot(x1,y1,x2,y2)
if x1==x2 and y1==y2 then return 0 end
local angle=math.atan((x2-x1)/(y2-y1))
if y1-y2<0 then angle=angle-math.pi end
if angle>0 then angle=angle-2*math.pi end
return -angle
end
function mouseControl()
target.x,target.y = love.mouse.getPosition()
local rot = getRot(target.x,target.y,tank.x,tank.y)
tank.cannon.rot = rot --大炮的角度为坦克与鼠标连线的角度
end
function love.update(dt)
keyControl()
mouseControl()
end
function love.draw()
--车身
love.graphics.push()
love.graphics.translate(tank.x,tank.y)
love.graphics.rotate(tank.rot)
love.graphics.setColor(128,128,128)
love.graphics.rectangle("fill",-tank.w/2,-tank.h/2,tank.w,tank.h) --以0,0为中心
love.graphics.pop()
--炮塔
love.graphics.push()
love.graphics.translate(tank.x,tank.y)
love.graphics.rotate(tank.cannon.rot)
love.graphics.setColor(0,255,0)
love.graphics.circle("fill",0,0,tank.cannon.radius)
love.graphics.setColor(0,255,255)
love.graphics.rectangle("fill",-tank.cannon.w/2,0,tank.cannon.w,tank.cannon.h)
love.graphics.pop()
--激光
love.graphics.setColor(255,0,0)
love.graphics.line(tank.x,tank.y,target.x,target.y)
end