弹幕shooter(II)

我们继续制作弹幕shooter,上一章我们构思了一下游戏的技术需求以及做了一些基本的通用性的框架,这一章我们来思考一下游戏对象的设计问题。

弹幕shooter(II)

游戏对象

我们在基础教程里曾经说过游戏对象的问题。因为我们决定使用oop的方式组织代码,所以游戏对象是既有数据又有功能函数的集合。而由于数据和功能一般有继承关系,因此结构是树状的。(相比而言,组件式编程的游戏对象仅仅含有数据,所有游戏功能是接入的形式的,因此结构是集合形式的。)
树状的类结构,我们要实现功能的不断继承和叠加。也就是从最基本的功能开始,到最个别的功能。但是,oop是辅助你写程序的形式,而非你的绊脚石,因此,不必为了写复杂层级而写,而是按自己需求,放心,额外的代码重复不会造成程序的拖延。

游戏对象设计

对于一般的游戏引擎而言,比如cocos他的基本对象是节点,带图片的就是精灵了。精灵作为最基本的游戏对象,它有下面的一些特征。

  1. 位置
    位置是控制对象在游戏坐标系的位置的参数,一般有x,y,游戏游戏还需要z序来控制层叠的顺序。对于位置的延伸,可能还包括速度、加速度、帧位移等内容。当然还有旋转以及旋转锚点。
  2. 循环控制
    还拿cocos举例,只要你把某个节点加入到场景中,就可以自动循环了,而love因为全是手动控制,因此需要你自己添加。比如我们游戏沙盒有个game.objects={}容器,我们每次生成的时候,把他们添加进去就行了。而game的循环控制会帮你循环。
  3. 更新
    我们刚刚提到了循环,而循环的意义实际上就是逻辑更新和绘图更新。而对于精灵而言,更新的意义可能是位置的更新,以及帧动画的更新。这里提一些额外的话,love的编程模式是回调式的,也就是在控制流中不停的跳转到项目的各个角落。但是,回调的嵌套往往会造成一些麻烦,比如已经嵌套的回调,你不太容易重新指定回调的顺序,或者移除某些嵌套的回调。还有一种控制形式是基于消息和事件的,而精灵会在更新逻辑中监听系统的事件和消息,如果有自己相关的事件广播或者有发向自己的消息,那么就处理相应的逻辑,这种方式的好处是,某个精灵不会保存外部的ref,使得代码很干净,通过对消息系统的监听也比较容易的debug,消息系统配合调度器系统也比较好用(相比延迟回调而言)。不过消息系统的效率往往不及回调,而且思路上也比较复杂,因此按个人需求自己选择用那种模式。
  4. 绘制
    绘制分为两部分,一部分是绘制的数据,一部分是绘制的回调。
    从数据角度,我们可能需要image,quad,mesh,shader,canvas,animation等一大堆对象,以及颜色,混色方式,遮罩等控制,不过根据我们目前的项目而言,可以简化。
    从绘制回调,我们一般要把绘制对象按其位置、角度、缩放一一绘制。同时还要注意绘制顺序问题。
  5. 销毁
    销毁过程主要是释放一些无法自动释放的东西,比如保存在外部容器的对象。同时,在游戏沙盒中注销自己。

另外还要注意类的私有变量、公开变量及实例的变量。可能在lua中的oop并不是显式指明那些是私有,哪些是公开,哪些是受保护的,而且任何时候,任何位置的代码只要能够上查到他们,都可以编辑这些变量,使得Lua中的oop往往会存在一些陷阱。所以一定要理解这几种变量的作用、区别与定义方法。

游戏对象结构

base 游戏对象的基类,提供上文提到的循环加入与移除,基本绘图,基本的逻辑更新以及位置更新。一般而言,base可以用来表示一些没有互动的物体,比如背景图片,基本的UI内容等。
collidable 从base继承,加入碰撞初始化,碰撞筛选和碰撞遍历。绘制加入aabb的debugdraw方法。用来表示一些有碰撞却没有互动的物体,比如一些有碰撞的残骸等等。
bullet 从collidable继承,主要就是碰撞的检测了。以及特殊子弹的移动行为及特殊的绘制。
ship 从collidable继承,加入一些飞机的属性和行为,比如飞机的引擎火焰(从base继承),飞机的武器系统,飞机的移动控制,飞机摧毁(用来移除飞机火焰对象),飞机特有碰撞,飞机被弹,飞机开火等等。它是敌机和我机的基类,同时也可以是其他飞机的基类,比如飞机爆炸掉落的升级零件以及僚机等等。
enemy 从ship继承,是多有敌人飞机的基类,加入了敌人的行为控制。
player 从ship继承,是玩家控制飞机的类,也可以作为玩家其他机体的基类,主要是玩家控制飞机的行为。

其他小的物体的类,比如一些障碍物,装饰品等都是通过上面的类简单继承出来的,还有各种各样的敌人飞机,是通过继承敌机类,通过控制飞机类的基本属性,修改行为方式,继承一些特殊的子弹来实现的。

游戏类的形成思路

往往新手对于类没什么概念,也不太会用继承关系得到比较丰富的对象。而是针对某一个物体,写出单独的类。这样,实际上是可以的,只是每一次拓展,都需要复制粘贴很多的东西,修改多,而且代码比较混乱,某一种方法如果错误了,需要调整所有的代码。
对于新人,完全可以分为两步走,第一步,按照就事论事的原则来做,比如一上来我们可以做一个玩家控制的飞机对象。实际上,我们基础教程的前几课就足够你用了。只不过,之前是组件式的编程,行为写在对象的外面。而这一次,行为写在对象的里面。
我们很容易就可以做出比如玩家、子弹、敌人这些对象。(基础课程我们制作的那种)
然后,我们可以看出,这些对象有很多共通的地方,比如加入循环,比如移动方式,绘制方式等。我们按其各自功能集合的包含性,就可以提炼出一些基类出来。然后我们将代码重写一次,就可以得到树状的类层级了。
熟练之后,我们需要从简单到复杂的进行思考,不同的对象的共性,方法等,然后正向的铺设层级树。

重点代码

基类更新部分

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
function Base:update(dt)
if self.destroyed then return end --如果被销毁了就不再更新
self:translate(dt) --移动控制
if self.anim then self.anim:update(dt) end --动画更新
if self.tweens then --缓动更新
for i,tween in pairs(self.tweens) do
tween:update(dt)
end
end
end
function Base:draw()
love.graphics.setColor(self.color)
if self.anim then --动画绘制,参数与draw类似
self.anim:draw(self.x,self.y,self.rot,self.scaleX,self.scaleY,self.anchorX,self.anchorY)
end
if self.image then
if self.quad then --如果是大素材,则取其quad
love.graphics.draw(self.image,self.quad,self.x,self.y,self.rot - math.pi/2,
self.scaleX,self.scaleY,self.anchorX,self.anchorY)
else --单纯素材就直接整幅绘制
love.graphics.draw(self.image,self.x,self.y,self.rot - math.pi/2,
self.scaleX,self.scaleY,self.anchorX,self.anchorY)
end
end
if DEBUG then --用来矫正画面中心的
love.graphics.setColor(255, 0, 128)
love.graphics.circle(self.x,self.y,5)
end
end

collidable对象

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
function collidable:getAABB() --获取aabb信息,因为x,y本身是画面中心,而aabb要求的是左上角所以要额外计算
local w = self.scaleX*self.w/1.5 --一般而言aabb往往比图片本身小一点比较合适。这里取2/3图片宽
local h = self.scaleY*self.h/1.5
local x = self.x - w/2
local y = self.y - h/2
return x,y,w,h
end
function collidable:setPosition(l,t) --l,t是左上角,要按原比例还原到图片中心。
self.x = l + self.scaleX*self.w/3
self.y = t + self.scaleX*self.h/3
end
function collidable:initBump() --加入到碰撞世界中
game.world:add(self,self:getAABB())
end
function collidable:destroy() --摧毁物体时也要把aabb移除
if not self.destroyed then
game.world:remove(self)
end
base.destroy(self)
end
function collidable.collidefilter(me,other) --碰撞过滤
return bump.Response_Cross
end
function collidable:collision(cols) --碰撞行为留空
end
function collidable:updateBump() --每一次要更新碰撞世界的绑定aabb位置,根据碰撞结果来重新调整物体位置。
local ox,oy = self:getAABB()
local tx,ty ,cols = game.world:move(self,ox,oy,self.collidefilter)
self:setPosition(tx,ty)
self:collision(cols)
end

ship类的武器系统

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
function ship:weaponUpdate(dt) --武器冷却
for _,weapon in ipairs(self.weapons) do
weapon.timer = weapon.timer - dt
end
end
function ship:fire(gun,offx,offy,rot)
if gun then --如果传入子弹类则直接开火
gun(self,offx,offy,rot)
return
end
for _,weapon in ipairs(self.weapons) do --否则如果飞机某个武器更新完毕,就开火,并进入冷却
if weapon.timer < 0 then
weapon.bullet(self,weapon.offx,weapon.offy,weapon.rot)
weapon.timer = weapon.cd
end
end
end
function ship:damage(p) --受伤害,如果是无敌则不扣减
if self.overwhelming then return end
self.hp = self.hp - p
if self.hp<0 then --如果生命小于0 则爆炸
self:destroy()
end
end
function ship:destroy()
collidable.destroy(self)
Boom(self,self.scaleX*2) --生成一个爆炸动画,设个是从base继承来的
Frag(self) --生成一个碎片动画,同样是base
end
function ship:update(dt)
collidable.update(self,dt)
for _,fire in ipairs(self.fireAnims) do --引擎动画更新
fire:update(dt)
end
self:weaponUpdate(dt) --武器更新
if self.behavior then --行为更新,之所以写到这里,是因为如果你希望,也可以托管一下玩家控制的飞机。
self:behavior()
end
end

玩家player类中的一些方法

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
function player:onDestroy() --如果摧毁了就gameover了
delay:new(2,function() game:gameover() end)
end
function player:keyControl() --按键控制,基础教程第一章就讲了
local down = love.keyboard.isDown
local dt = love.timer.getDelta()
if down("w") then
self.y = self.y - self.speed * dt
elseif down("s") then
self.y = self.y + self.speed * dt
end
if down("a") then
self.x = self.x - self.speed * dt
elseif down("d") then
self.x = self.x + self.speed * dt
end
if down("space") then
self:fire()
end
end
function player:limit() --你不希望玩家把飞机弄到屏幕外面吧。
if self.x<16 then
self.x = 16
elseif self.x>484 then
self.x = 484
end
if self.y<16 then
self.y = 16
elseif self.y>784 then
self.y = 784
end
end
function player:collision(cols)
for i,col in ipairs(cols) do
local other = col.other
if other.tag == "enemy" then --飞机相撞,来啊,互相伤害啊。
self:damage(other.hp)
other:damage(self.hp)
elseif other.tag == "item" then --如果是物品就获取了。
self:getItem(other)
other:destroy()
end
end
end

敌人enmey类

1
2
3
4
5
6
7
8
9
10
11
12
13
function enemy:behavior() --敌人的行为其实可以任意脚本的,这里这个就是沿着自身方向飞行并开火
if self.state ~= "ok" then
self.vx = self.speed * math.sin(self.rot)
self.vy = -self.speed * math.cos(self.rot)
self.state = "ok"
end
self:fire()
end
function enemy:outTest() --敌人远离屏幕了就摧毁它吧。这个范围自己把握。因为有时候离开屏幕我们还想让它回来呢。
if self.y>1000 or self.y<-1000 then self:destroy() end
if self.x<-500 or self.x>1000 then self:destroy() end
end

子弹类bullet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Bullet.canbeTarget = { --子弹的目标筛选,只有为真的才可以击中
bullet = false,
player = true,
enemy = true,
item = false
}
function Bullet:collision(cols)
for _,col in ipairs(cols) do
local other = col.other
if self.canbeTarget[other.tag] and self.parent.tag~= other.tag then --只有子弹发射者的标签不同才可以击中,比如一个是敌人,一个是玩家
self:destroy()
other:damage(self.damage)
end
end
end

好啦,基本类都差不多了,实际上,我们每建立一个类,我们就可以把他们扔到场景上测试一下。下一章我们将介绍如何通过摆放和生成这些类的实例来组成游戏。