更新于 

2D横版角色移动-攻击-跳跃-冲刺-闪避

原视频教程

准备工作

将骨骼动画文件从DragonBones中导出之后,一共生成了3个文件:

  • hero_ske.json 骨骼数据
  • hero_tex.json tex定位数据
  • hero_tex.png tex拼接图片

创建空节点作为hero,新增DragonBones组件,
将导出的json文件赋值到对应的位置:

角色移动与相机跟随

角色横向移动

角色横向移动,需要进行如下处理:

  • 角色可以横向移动
  • 相机跟随
  • 方向改变
  • 缓动
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
@ccclass
export default class Hero extends cc.Component {
/**
* 跟随相机
*/
@property(cc.Node)
camera: cc.Node = null
/**
* 英雄速度
*/
@property
heroSpeed:number = 1
/**
* 移动方向
*/
dir:number = 1
/**
* 控制自动切换方向计时器
*/
changeDirTime = 100
update(dt){
// 自动切换方向
this.changeDirFunction()
// 英雄移动
this.heroMove()
// 相机跟随
this.cameraMove()
}

// 自动切换方向
changeDirFunction(){
this.changeDirTime --
if(this.changeDirTime <= 0){
// 像反方向产生一个随机的力
this.changeDirTime = 50 + Math.floor(Math.random() * 101)
this.dir *= -1
// 转换方向时,从0速度开始加速
this.heroSpeed = 0
}
}

// 英雄移动
heroMove(){
// ease-in 缓入动画
if(this.heroSpeed <= 3){
this.heroSpeed += 0.1
}
switch(this.dir){
case 1: // 向右移动
this.node.x += this.heroSpeed;
break;
case -1:
this.node.x -= this.heroSpeed
break;
}
}

// 摄像机跟随
cameraMove(){
this.camera.x = this.node.x
this.camera.y = this.node.y
}
}

移动手柄

创建移动手柄

创建移动手柄UI组件:

新建rocker.ts脚本,用于控制控制器中移动手柄的操作:

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
@ccclass
export default class Rocker extends cc.Component {
/**
* 移动手柄
*/
@property(cc.Node)
Stick: cc.Node = null
/**
* 最大半径
*/
@property
max_r:number = 100
start () {
// 注册触摸事件
// 开始触摸
this.node.on(cc.Node.EventType.TOUCH_START,(e:cc.Event.EventTouch) => {
const w_pos = e.getLocation() // 获取触摸点世界坐标
const n_pos = this.node.convertToNodeSpaceAR(w_pos) // 转化为该节点的坐标
// 控制移动距离
let len = n_pos.mag() // 返回原点到该点坐标向量的长度
if(len > this.max_r){
// 使用三角函数实现
n_pos.x = this.max_r * n_pos.x / len
n_pos.y = this.max_r * n_pos.y / len
}
this.Stick.setPosition(n_pos)
})
// 触摸移动
this.node.on(cc.Node.EventType.TOUCH_MOVE, (e:cc.Event.EventTouch) => {
const w_pos = e.getLocation()
const n_pos = this.node.convertToNodeSpaceAR(w_pos)
// 控制移动距离
let len = n_pos.mag()
if(len > this.max_r){
n_pos.x = this.max_r * n_pos.x / len
n_pos.y = this.max_r * n_pos.y / len
}
this.Stick.setPosition(n_pos)
})
// 触摸结束
this.node.on(cc.Node.EventType.TOUCH_END, (e:cc.Event.EventTouch) => {
this.Stick.setPosition(0, 0)
})
// 触摸取消
this.node.on(cc.Node.EventType.TOUCH_CANCEL, (e:cc.Event.EventTouch) => {
this.Stick.setPosition(0, 0)
})
}
}

控制玩家左右移动

手柄控制移动

关键点:

  • 检测手柄移动坐标的x值
    • 正值向右移动
    • 负值向左移动
  • 检测手柄的触摸结束和取消事件
    • 取消时玩家停止行动
  • 相机设置最大跟随参数
    • 保留相机和玩家之间的一定移动距离
方向移动和停止

手柄每次监听到移动都触发玩家的方位判断

手柄脚本:

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

@ccclass
export default class Rocker extends cc.Component {
// ...
/**
* 手柄移动的玩家
*/
@property(cc.Node)
Hero:cc.Node = null
heroJs = null
start () {
this.heroJs = this.Hero.getComponent(HeroComp)
this.node.on(cc.Node.EventType.TOUCH_START,(e:cc.Event.EventTouch) => {
// ...
this.heroJs.setDir(n_pos.x) // 玩家进行方位判断
})
// 触摸移动
this.node.on(cc.Node.EventType.TOUCH_MOVE, (e:cc.Event.EventTouch) => {
// ...
this.heroJs.setDir(n_pos.x)
})
// 触摸结束
this.node.on(cc.Node.EventType.TOUCH_END, (e:cc.Event.EventTouch) => {
// ...
this.heroJs.cancelMove()
})
// 触摸取消
this.node.on(cc.Node.EventType.TOUCH_CANCEL, (e:cc.Event.EventTouch) => {
// ...
this.heroJs.cancelMove()
})
}
}

Hero脚本:

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

@ccclass
export default class HeroComp extends cc.Component {
/**
* 相机速度
*/
@property
cameraSpeed:number = 0
/**
* 英雄速度
*/
@property
heroSpeed:number = 0
/**
* 移动方向
*/
dir:number = 0
// 停止移动
cancelMove(){
this.dir = 0
}
// 根据x设置移动方向
setDir(x){
let preDir = this.dir
if(x>0){
// 向左
this.dir = 1;
if(this.node.scaleX != -0.3) this.node.scaleX = -0.3

}else if(x<0){
// 向右
this.dir = -1
if(this.node.scaleX != 0.3) this.node.scaleX = 0.3
}
if(preDir !== this.dir){
// 修改方位/停下/开始行动,hero和相机的移动速度同时置0
this.heroSpeed = 0
this.cameraSpeed = 0
}
}
}
相机跟随

添加最大起始跟随距离,
对相机跟随速度进行缓动处理:

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
@ccclass
export default class HeroComp extends cc.Component {
// ...
/**
* 跟随相机
*/
@property(cc.Node)
camera: cc.Node = null
/**
* 相机速度
*/
@property
cameraSpeed:number = 0
// 摄像机跟随
cameraMove(){
// 相机进行阻尼式跟随
let dis = Math.abs(this.camera.x - this.node.x)
if(dis < 5) return
if(this.cameraSpeed <= 3){
this.cameraSpeed += 0.1
}
if(this.camera.x < this.node.x){
this.camera.x += this.cameraSpeed
}else{
this.camera.x -= this.cameraSpeed
}
}
}

动画切换

需要给玩家添加状态机参数进行控制:

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

@ccclass
export default class HeroComp extends cc.Component {
// 龙骨组件
dbDisplay = null
dbArmature = null

// 状态机 0待机 1奔跑
state = 0
onLoad(){
this.dbDisplay = this.node.getComponent(dragonBones.ArmatureDisplay)
this.dbArmature = this.dbDisplay.armature()
}
// 播放动画
animFunction(animName){
// 调用接口播放动画
this.dbArmature.animation.fadeIn(animName,-1, -1, 0, ANI_GROUP, dragonBones.AnimationFadeOutMode.All)
}

// 停止移动
cancelMove(){
this.dir = 0
this.state = 0
this.animFunction('待机')
}

// 根据x设置移动方向
setDir(x){
let preDir = this.dir
this.state = 1
if(x>0){/*向左略*/}
else if(x<0){/*向右略*/}
if(preDir !== this.dir){
// 转换方向/由待机到奔跑状态
this.heroSpeed = 0
this.cameraSpeed = 0
this.animFunction('奔跑')

}
}
}

物理组件

物理组件

全局开启物理引擎:

1
2
3
4
5
6
7
8
@ccclass
export default class Game extends cc.Component {
physicDirector = null
onLoad(){
this.physicDirector = cc.director.getPhysicsManager()
this.physicDirector.enabled = true
}
}

给Hero和Ground地面添加物理碰撞检测组件,
同时给项目添加Hero和Ground的分组
开启2组之间的碰撞检测

由于之前Hero的移动是通过直接修改坐标实现的,
这里改为给Hero的刚体添加线性速度实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
heroMove(){
const v = this.body.linearVelocity
// ease-in
if(this.heroSpeed <= 6){
this.heroSpeed += 0.4
}

switch(this.dir){
case 1: // 向右移动
// this.node.x += this.heroSpeed;
v.x += this.heroSpeed
break;
case -1:
// this.node.x -= this.heroSpeed
v.x -= this.heroSpeed
break;
}
this.body.linearVelocity = v
}

Hero跳跃

Jump
  • 跳跃时,添加一个向上的线性速度
  • 需要监听跳跃动画的播放
    • 跳跃开始,播放跳跃动画
    • 跳跃结束,需要回到奔跑或待机的状态
  • 增加跳跃CD的设置
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
@ccclass
export default class HeroComp extends cc.Component {
// 龙骨组件
dbDisplay = null
// 状态机 0待机 1奔跑
state = 0
// 跳跃间隔
jumpCD:number = 0
onLoad(){
this.dbDisplay = this.node.getComponent(dragonBones.ArmatureDisplay)
// 绑定动画播放结束事件
this.dbDisplay.on(dragonBones.EventObject.COMPLETE, this.aniComplete, this)
}
update(dt){
// CD减少
if(this.jumpCD >0){
this.jumpCD --
}
}
// 动画播放结束事件
aniComplete(){
// 回到状态机所在的状态
if(this.state == 1){
this.animFunction('奔跑')
}else if( this.state == 0){
this.animFunction('待机')
}
}
// 播放动画
animFunction(animName){
// 调用接口播放动画
this.dbArmature.animation.fadeIn(animName,-1, -1, 0, ANI_GROUP, dragonBones.AnimationFadeOutMode.All)
}
// 跳跃
jump(){
// 如果跳跃cd未过,无法跳跃
if(this.jumpCD >0) {
return
}
const v = this.body.linearVelocity
v.y = 450 // 增加一个向上的力
this.body.linearVelocity = v
this.animFunction("普通跳跃")
this.jumpCD = 50 // 重置跳跃cd
}
}

Hero 攻击

普通攻击动画

根据攻击间隔,
播放攻击动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

@ccclass
export default class HeroComp extends cc.Component {
// 攻击间隔
attachCD:number = 0;
update(dt){
if(this.attachCD > 0){
this.attachCD --
}
}
// 攻击
attack(){
if(this.attachCD > 0){
return
}
// 播放攻击动画
this.animFunction("普攻_01")
this.attachCD = 50
}
}
连招攻击
  • 使用一个数组存放连招需要用的动画名字
  • 引入一个连招CD用于判断角色每次攻击之间的时间间隔,
  • 引入一个用于记录当前连招的变量
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

// 连招数组
const attAnimList = ["普攻_01","普攻_02","普攻_03","普攻_04",]
@ccclass
export default class HeroComp extends cc.Component {
// 连招间隔
combatCD:number = 0
update(dt){
if(this.attCount > 0){
if(this.combatCD > 0){
this.combatCD --
if(this.combatCD <= 0){
this.attCount = 0
}
}
}
}

// 攻击
attack(){
if(this.attachCD > 0){
return
}
this.animFunction(attAnimList[this.attCount])
// 增加连招序号
this.attCount = (this.attCount + 1) % attAnimList.length
switch(this.attCount){
// 根据当前连招序号,修改连招CD
case 1: this.combatCD = 120;break;
case 2: this.combatCD = 140;break;
case 3: this.combatCD = 160;break;
}
this.attachCD = 50
}
}
攻击检测

使用攻击范围和受击范围包围盒式检测,
需要知道如下参数:

  • 攻击方 a
    • 当前坐标 a.x, a.y
    • 此时朝向 a.dir
    • 正面攻击范围 a.aR.x_01
    • 背面攻击范围 a.aR.x_02
    • 攻击高度 a.aR.y
  • 受击方 n
    • 当前坐标 n.x, n.y
    • 此时朝向 n.dir
    • 正面受击范围 n.hR.x
    • 背面受击范围 (此处和图不太一样,背面受击和正面受击设置一样的范围)
    • 受击高度 n.hR.y
      存在下面几种可能

已经知道玩家攻击方向朝左,就能知道玩家的攻击包围盒

存在碰撞的可能性有很多,一一判断比较麻烦,所以这里判断不会碰撞的情况

1
2
3
4
5
6
let hit = !(
a.x - a.aR.x_01 > n.x + n.hR.x || // 正面打不到
a.x + a.aR.x_02 < n.x - n.hR.x || // 背面到不到
a.y > n.y + n.hR.y || // 上面打不到
a.y + a.aR.y < n.y // 下面打不到
)
1
2
3
4
5
6
let hit = !(
a.x + a.aR.x_01 < n.x - n.hR.x || // 正面打不到
a.x - a.aR.x_02 > n.x + n.hR.x || // 背面打不到
a.y > n.y + n.hR.y || // 上面打不到
a.y + a.aR.y < n.y // 下面打不到
)

根据上面的判断条件,创建用于检测战斗检测的方法:

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
/**
* @param aNode 攻击方Node
* @param a 攻击方自定义组件类
* @param nNode 受击方Node
* @param n 受击方自定义组件类
* @param _faceDir 攻击方朝向
*/
export const checkCollide = (aNode, a, nNode, n, _faceDir) => {
/**
* 当受攻击方的受击盒 和攻击方的攻击盒 有交叉部分时,
* 检测到攻击
*/
switch(_faceDir){
case -1:
// 向左
return !(
aNode.x - a.aR.x_01 > nNode.x + n.hR.x || // 正面打不到
aNode.x + a.aR.x_02 < nNode.x - n.hR.x || // 背面到不到
aNode.y > nNode.y + n.hR.y || // 上面打不到
aNode.y + a.aR.y < nNode.y // 下面打不到
)
break;
case 1:
// 向右
return !(
aNode.x + a.aR.x_01 < nNode.x - n.hR.x || // 正面打不到
aNode.x - a.aR.x_02 > nNode.x + n.hR.x || // 背面打不到
aNode.y > nNode.y + n.hR.y || // 上面打不到
aNode.y + a.aR.y < nNode.y // 下面打不到
)
break;
}
}

Hero中用于进行击中检测的脚本:

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
@ccclass
export default class HeroComp extends cc.Component {
// 身体朝向 1=右侧 -1=左侧
bodyDir:number = 1

// 攻击距离
aR = {
x_01: 0, // 正面攻击距离
x_02: 0, // 背面攻击距离
y: 0, // 垂直攻击高度
}
onLoad(){
// 重置身体转向右侧
this.node.scaleX = -0.3

// 初始化碰撞盒
this.aR.x_01 = this.node.width * 0.3 * 1 // 朝前一身
this.aR.x_01 = this.node.width * 0.3 * 0.5 // 朝后半身
this.aR.y = this.node.height * 0.3 // 高度全身

// 添加一个动画帧来控制
this.dbDisplay.on(dragonBones.EventObject.FRAME_EVENT, this.frameEvent, this)
}
// 动画中的帧事件需要在龙骨中加入
frameEvent(e){
switch(e.name){
// 攻击动画播放到一半,开始判定敌人受伤
case "attack":
let enemyNode = window.game.getComponent(Game).enemy_01
let enemy = enemyNode.getComponent(Enemy)
// 判断是否击中
let hit = checkCollide(
this.node, this,
enemyNode, enemy,
this.bodyDir
)
if(hit){
// 调取敌人的被击中方法
enemy.getHit()
}
break;
}
}
}

Enemy中用于检测被击中的脚本:

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
const ANI_GROUP = "normalGroup"
const {ccclass, property} = cc._decorator;

// 受击数组
const hitAnimList = ["受击_01","受击_02","受击_03","受击_04",]
@ccclass
export default class Enemy extends cc.Component {

// 敌人受击范围
hR = {
x:0,
y:0
}
dbDisplay = null
dbArmature = null

// 身体朝向
bodyDir = 1

onLoad(){
this.setFaceDir(1)
this.dbDisplay = this.node.getComponent(dragonBones.ArmatureDisplay)
this.dbArmature = this.dbDisplay.armature()
this.dbDisplay.on(dragonBones.EventObject.COMPLETE, this.aniComplete, this)
}
// 播放动画
animFunction(animName){
// 调用接口播放动画
this.dbArmature.animation.fadeIn(animName,-1, -1, 0, ANI_GROUP, dragonBones.AnimationFadeOutMode.All)
}

// 被击中脚本
getHit(){
// 随机选择被击中动画
let index = Math.floor(Math.random() * hitAnimList.length)
let ani_name = hitAnimList[index]
this.animFunction(ani_name)

// 收到伤害后,面向hero
let dir = 1
if(this.node.x > window.game.hero.x){
dir = -1 // 向右转
}
this.setFaceDir(dir)
}
setFaceDir(dir){
this.bodyDir = dir
this.node.scaleX = 0.3*-dir
}
// 动画播放结束事件
aniComplete(){
this.animFunction('待机')
}
start () {
// 初始化敌人受伤害的范围
this.hR.x = this.node.width * 0.3
this.hR.y = this.node.height * 0.3
}
}
对全局对象的扩充

由于像enemy、hero这种节点比较关键,全局都有可能会用到,
所以可以将他们集中挂载到window全局对象下,

先在根目录下加入global.d.ts文件用于覆写window的属性

1
2
3
declare interface Window{
game?: any
}

在全局挂载脚本game中加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@ccclass
export default class Game extends cc.Component {
physicDirector = null

@property(cc.Node)
enemy_01:cc.Node = null

@property(cc.Node)
hero:cc.Node = null

onLoad(){
// 挂载game到window上
window.game = this
this.physicDirector = cc.director.getPhysicsManager()
this.physicDirector.enabled = true
}
}