假设大家都了解Cocos(3.3rc0),以及用过Cocos Code IDE(1.01)。这篇文章主要的内容是这个游戏的的实现思路,具体细节会暂时(就)跳(是)过(懒)。
游戏总共有四个场景,分别是
我们着重讲一下BattleScene这个场景的结构
BattleScene的层次
这个层次还是比较简单的。一个Scene加一个Layer,其他元素都是AddChild到Layer上,包括英雄和怪物。其中比较特殊的是camera,这是一个摄像机对象,用来控制画面远近、视角等,另外uiLayer是挂在camera上的(我们在摄像机跟踪一节解释这个做法)
然后绝大部分逻辑由gameController这个定时器函数来控制,其中包括碰撞检测、攻击指令、摄像头跟踪、以及游戏主逻辑。这个定时器函数每一帧都会被引擎调用。
最后currentLayer支持触摸,这个用来触发英雄的特殊技能和控制镜头。
碰撞检测
collisionDectect是gameController内第一个被调用的函数,用来检查所有游戏中的所有角色。如果角色离的太近,会让他们’推‘开;如果超过游戏场景边缘,会把他们’挪‘回来。
每一个游戏角色有一个圆形区域,这个区域大小由_radius决定;另外它有重量,由_mass决定;
所有有效的游戏角色都被放入了HeroManager和MonsterMananger内,所以碰撞检测的逻辑就是:取出每一个角色,与剩下的角色进行计算距离,小于两者半径之和判定为发生‘碰撞’;按两者中心点的直线方向,计算应该后退的距离,设置新的位置坐标(由重量决定后退的比例)。
碰撞检测结束后,会顺便检测角色是否超过预先设定的场景边缘位置,这在isOutOfBound内实现。
注意,碰撞检测不会处理已经‘死亡’的游戏角色,碰撞检测中HeroManager和MonsterManager会移除死亡的游戏角色。
摄像机跟踪
在我们的游戏中,摄像机有两种跟踪方式:
第一种跟随三个主角所在位置自动移动,同时根据玩家手指移动稍微改变位置,第二种是游戏角色释放特殊技能时切换特殊的视角。
这两种摄像机跟踪都在同一个函数moveCamera内实现,核心逻辑是通过setPosition3D不断改变摄像机的位置,同时通过lookAt让摄像机‘看’向正确的位置。你可以想象一支笔的两端,一端是摄像头位置,一端是它‘看’的位置。
另外,由于摄像机位置需要从A点改变到B点,看的位置从a点到b点,我们不能直接setPosition3D(B)和lookAt(b),而是需要计算从A到B,以及从a到b的差值位置。在游戏中我们通过cc.pLerp(A, B)来计算得到需要逐步移动的位置。
在BattleScene的onTouchMoved函数内,玩家通过触摸改变cameraOffset的值,摄像机位置会随这个值改变,于是实现了玩家在限制范围内控制摄像机位置的功能。
最后,我们把uiLayer挂在摄像头上(addChild),就是由于摄像机位置一直变化。如果放在currentLayer上,摄像机一移动,UI界面就看不到了。现在uiLayer会随着摄像机移动改变位置,所以我们就能一直看到UI界面,这就像你带了一副眼镜,不管你怎么转头,眼镜架还是在你鼻梁上。
最后用几张图来表示摄像机在3D空间的位置和移动的方式
左图是引擎默认情况下,摄像机的位置。左图的绿色红色箭头是x轴,蓝色箭头是y轴,绿色箭头为z轴。通过阅读Camera::initDefault源码,可以知道,在3D模式下,摄像机是位于(winSize.width/2, winSize.height/2, getZEye),并向(winSize.width/2, winSize.height/2)方向看。
右图是游戏中摄像机的位置,在运行时,它会根据英雄当前所在的位置不断调整x轴和y轴坐标,而z轴坐标不变。
UI层
这一层用来显示游戏角色头像、血量、愤怒值,角色受到攻击头像会震动,角色死亡后会变成黑白色,可以触摸角色头像触发特殊技能攻击敌人,胜利时显示成功界面。
值得一说的是,我们把UI层逻辑和游戏逻辑拆分开,用注册消息\函数回调的方式来解决数据传输的问题。
在BattleScene中,我们注册了BLOOD_MINUS,ANGRY_CHANGE消息,用来接收血量变化、愤怒值变化,通过回调函数boodMinus,angryChange,调用uiLayer改变血量,愤怒值。
角色类结构
游戏角色类都放在actors文件夹下,它们的关系如下
每个游戏角色在创建的时候,都拥有一个自身update函数,运行时每帧调用一次。这个update函数负责调用如下逻辑:
baseUpdate函数,它负责定时执行角色的AI逻辑(具体内容我们在角色AI一节解释)。
stateMachineUpdate函数,它负责根据目前角色状态值让角色表现对应状态的动作、动画。
movementUpdate函数,它负责两件事情,一是在不断改变自己的位置,让角色在游戏中移动;二是改变旋转角度,让角色面向目标方向。
角色的状态机
角色的状态在GlobalVariables.lua文件中定义,由下图几个状态组成
角色的状态被stateMachineUpdate控制,不断的在以上六种状态中切换,切换的逻辑如下图:
walkUpdate的逻辑是:如果角色已经有一个目标(角色AI中实现),如果目标在攻击范围内,就切换到attackMode;如果不在攻击范围,继续移动过去。如果没有目标,继续往右走,如果右边不能再走了,就停在那里发呆(移动在movementUpdate里实现)。
attackUpdate的逻辑是:每次攻击需要花一定时间,首先判断是否到了下一次攻击的时间;如果是,根据概率发出普通攻击或者特殊攻击,然后播放攻击动画。特殊攻击的时候做三件事情,一、除了发技能的角色意外的游戏画面全部暗下来(通过发送SPECIAL_PERSPECTIVE消息,调用回调函数实现);二、让摄像头移动,形成特写效果;三、通过setTimeScale降低scheduler的速度,形成慢动作效果。此外无论是特殊攻击还是普通攻击,会生成攻击环(这个在攻击指令一节中说明)。
knockingUpdate的逻辑是:每次被击中需要一定时间播放受击动画,首先判断被攻击动画是否播放结束;如果是,判断目标是否在自己的攻击范围内,如果是,进入attackMode,如果不是,切换到walkMode。
角色AI
角色的AI是被baseUpdate驱动的,每个角色有自己的_AIFrequency。所以AI的基本逻辑是:首先判断是否到了下一次执行AI的时间,如果是,执行AI。
AI逻辑主要是寻找攻击目标,如果没有找到目标,根据当前状态切换到walkMode或者idleMode;如果找到目标,判断目标是否在自己的攻击范围内,如果是,切换到attackMode,如果不是,切换到walkMode去靠近目标。
攻击指令
攻击指令是一个独立类,它的baseClass是BasicCollider,结构如下:
其中的sp变量就是游戏中我们看到的冰锥、冰球、箭支,分别是一个sprite或者sprite3D对象实例。
但是真正起到攻击作用的,是BasicCollider的攻击区域,它可能是一个扇形、圆形或者圆环,如图
minRange决定攻击最小半径,可以为0;maxRange大于minRange;angle决定了扇形圆心角,值的范围在(0, 360];facing决定方向;这几个属性决定了攻击区域的大小。例如A和B,唯一的差别是A的minRange=0,当目标在A或者B区域内,都会受到伤害。
攻击指令在创建时,就被加入到AttackManager里(BasicCollider:initData)。而solveAttacks(还记得在哪里调用吗)中会循环遍历AttackManager,每次取出一个攻击指令,判断它与游戏角色是否发生碰撞,如果没有,就继续与其他游戏角色比较;如果有,调用onCollide,播放攻击特效、声效,调用被攻击角色的hurt函数;在循环结束前,从AttackManager移除已经无效的攻击指令(无效是指curDuration > duration)。
攻击指令中还有两种特殊指令,一种是可以移动的攻击指令,比如飞行的冰球、箭支,一种是DOT(Damage over time,在一段时间内不断对目标造成伤害),只有这种指令才有curDOTTime和DOTTimer属性。在solveAttacks的循环结束前,有一个attack:onUpdate函数被调用,这个函数会不断改变攻击指令的位置,以实现‘飞行’的效果;另外,这函数会改变curDOTTime的值,这样,当curDOTTime > DOTTimer时,onCollide就会对处于攻击范围内的目标产生伤害。
游戏主逻辑
主逻辑靠GameMaster:update驱动,这个函数在BattleScene中gameController中被调用。
游戏主逻辑包含二个功能:第一,创建英雄和怪物;第二,在合适的时机显示怪物和界面。
值得一说的是,在游戏进行中,创建怪物会导致游戏卡顿,所以我们在创建的时候就把所有的怪物放在了对应的pool里,同时也addChild到了currentLayer上,同时设置为隐藏;在游戏进行中,我们才把pool里的怪物拿出来,放到MonsterManager里面。