Cocos Creator 小教程:创建一个虚拟摇杆
2020.03.24
今天更新了creator 版本到2.3.2,发现cc.Node.position
的类型变为cc.Vec3了,原来是2的,记得给节点位置套上类型转换:cc.v2(position),懒得改了。。。手机上改代码真的难受。
还有发现Action被弃用了,建议换成缓动系统cc.tween(),可以找文档了解一下。
开始之前
在开始这篇文章之前,笔者想先说一说自己的目的。笔者学生,业余爱好写写代码,游戏开发只能算是刚开始学习。有一句老套的话,如果你教会了一个人,那你一定不止懂你教的东西。所以我决定针对最近在学习过程中遇到的问题,写点小教程来加深了解。至于为什么叫“小教程”——笔者一时也没想到比“教程”更合适的称呼,直接这样叫又有种大惊小怪的感觉,所以就加了个“小”字。
如果笔者在文章中思路或方法上有什么错漏的地方,望不吝赐教。
需求分析
游戏角色的操作摇杆,通过移向不同方向,来控制角色移动,流程就是:用户触摸摇杆,而摇杆在方向盘范围内跟随用户的手指移动,直至用户放开手指,摇杆回到原来的位置。
诶?这不就是一个跟随手指移动的功能嘛?很好实现啊?众所周知,Cocos Creator的特点之一就是组件化,所以我准备实现一个跟随手指的组件,只要将它加入节点,就可以实现跟随手指的功能,进而实现我们的虚拟摇杆。
代码实现
基础功能
首先,我们定义一个FollowTouch类继承自cc.Component
:
const { ccclass, property } = cc._decorator;
@ccclass
export default class FollowTouch extends cc.Component {
}
既然是摇杆,那肯定有一个触发范围,只要用户触摸这个区域内任何位置,都将触发跟手。最合适的范围就是作为摇杆背景的方向盘了,也就是摇杆的父节点。
写到这里,作者突然想到,市面上有不少游戏中摇杆的操作并不是位于固定的位置,而是在某一块屏幕区域滑动,如屏幕左侧,任意位置滑动都可以使主角移动。
既如此,单单父节点是不能满足需求了,那我们声明一个cc.Node
类型的属性,当触摸这个属性指定的节点时就触发跟手。
// 在这个节点所在区域范围内跟手将被触发;
@property(cc.Node)
container: cc.Node = null;
onLoad(): void {
if (this.container == null) this.container = this.node.parent;
}
接下来,我们为这个容器节点注册触摸事件监听:
onEnable(): void {
this.subscribe();
}
onDisable(): void {
this.unsubscribe();
}
onDestroy(): void {
this.unsubscribe();
}
private subscribe(): void {
this.container.on(cc.Node.EventType.TOUCH_START, this.onTouchStart, this);
this.container.on(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.container.on(cc.Node.EventType.TOUCH_END, this.onTouchEndOrCancel, this);
this.container.on(cc.Node.EventType.TOUCH_CANCEL, this.onTouchEndOrCancel, this);
}
private unsubscribe(): void {
this.container.off(cc.Node.EventType.TOUCH_START, this.onTouchStart, this);
this.container.off(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.container.off(cc.Node.EventType.TOUCH_END, this.onTouchEndOrCancel, this);
this.container.off(cc.Node.EventType.TOUCH_CANCEL, this.onTouchEndOrCancel, this);
}
private onTouchStart(event: cc.Event.EventTouch): void {
}
private onTouchMove(event: cc.Event.EventTouch): void {
}
private onTouchEndOrCancel(): void {
}
如果有对Cocos Creator 事件系统这块还不太了解的读者,建议参考Cocos Creator 官方文档还有cc.Node.EventType定义的系统事件。
上面我们说到,有些虚拟摇杆会在某个屏幕区域都生效,所以在这种情况下,整个摇杆的位置都是不固定的,直到用户按下屏幕。所以我设计了一个枚举类,里面定义了几种不同的行为方式,来处理用户在触摸屏幕时产生的节点变化。
enum Mode {
/** 直接拖动节点,手指离开屏幕时节点不会回到原来的位置; */
DRAG,
/** 节点跟随手指移动,手指离开屏幕时节点将回到原来的位置; */
STICKY,
/** 节点位置不固定,节点跟随手指移动,手指离开屏幕时节点不再可见; */
ANYWHERE,
}
在FollowTouch
类内声明属性:
@property({type: cc.Enum(Mode)})
mode: Mode = Mode.DRAG;
并在各个回调方法里实现了他们各自的逻辑。这里有一个需要注意的地方,那就是在Mode.STICKY
和Mode.ANYWHERE
的情况下,节点最终都要回到原来的位置的,所以我们定义一个私有变量,来记录需要返回的坐标点:
/** 用来设置节点回弹、显示或消失时的动画时长; */
@property({
type: cc.Integer,
min: 0
})
animDuration: number = 0;
private _positionWillBack: cc.Vec2 = null;
onEnable(): void {
// 在Mode.STICKY下,_positionWillBack是固定不变的,直接取他此时的坐标;
if (this.mode == Mode.STICKY) this._positionWillBack = this.node.position;
this.subscribe();
}
private onTouchStart(event: cc.Event.EventTouch): void {
// 触摸事件给予的坐标是世界坐标,我们将他转为节点局部空间内的坐标;
const position = this.node.parent.convertToNodeSpaceAR(event.getLocation());
// 在ANYWHERE模式下,节点默认不可见,所以现在要将它显示出来;
if (this.mode == Mode.ANYWHERE) {
this._positionWillBack = position;
this.node.runAction(cc.fadeIn(this.animDuration));
}
this.node.setPosition(position);
}
private onTouchMove(event: cc.Event.EventTouch): void {
this.node.setPosition(this.node.position.add(event.getDelta()))
}
private onTouchEndOrCancel(): void {
switch (this.mode) {
case Mode.STICKY: {
// 节点回弹;
const back = cc.moveTo(this.animDuration, this._positionWillBack);
this.node.runAction(back.easing(cc.easeBackOut()));
break;
} case Mode.ANYWHERE: {
// 手指离开屏幕,节点也就不再显示;
const back = cc.moveTo(this.animDuration, this._positionWillBack);
const fade = cc.fadeOut(this.animDuration);
this.node.runAction(cc.spawn(back, fade));
}
}
this._positionWillBack = null;
}
然而此时事情并没有结束,你会发现摇杆可以直接移动到屏幕的任何一处,这是不被允许的。所以我们需要为摇杆限制一个最大移动范围,我们可以声明一个cc.Integer
属性,当超过这个值,摇杆中心与方向盘中心的连线将不再有长度变化,而只有方向变化。
/** 此值为null时,不限制移动距离; */
@property({
type: cc.Integer,
min: 1,
})
maxDistance: number = null
private onTouchStart(event: cc.Event.EventTouch): void {
const position = this.node.parent.convertToNodeSpaceAR(event.getLocation());
if (this.mode == Mode.ANYWHERE) {
this._positionWillBack = position;
this.node.runAction(cc.fadeIn(this.animDuration));
}
this.updatePosition(position);
}
private onTouchMove(event: cc.Event.EventTouch): void {
const newPosition = this.node.position.add(event.getDelta());
this.updatePosition(newPosition);
}
/** 这个方法用来更新节点位置; */
private updatePosition(position: cc.Vec2): void {
const newPosition = position;
if (this.mode != Mode.DRAG) {
// 节点原位置与新位置的连线向量;
const line = newPosition.sub(this._positionWillBack);
if (this.maxDistance && this.maxDistance > 0) {
const ratio = line.mag() / this.maxDistance;
// 为什么这么算,下面有图片解释;
if (ratio > 1) newPosition.set(this._positionWillBack.add(this._line.div(ratio)));
}
}
this.node.setPosition(newPosition);
}
如此,节点就被限制在一个最大移动范围内了。关于这个被限制在最大距离的节点位置,我画了个草图,其中O点是坐标原点,M点是我们在Mode.STICKY
模式下节点的固定位置、Mode.ANYWHERE
模式下手指按下的开始位置,P点是手指触摸位置,P'点就是计算后的节点位置,用虚线圆代表最大移动距离。

由这个草图得知,原来的
newPosition
= OP,_line
= MP,改变后的newPosition
= OP',根据向量加法就可以得到OP' = OM + MP'。
控制主角移动
完成了上面的操作,想必这一步的实现大家都心中有数了,在updatePosition
方法中,我们声明了一个变量line
来表示节点新旧位置的连线,那我们将它暴露出去,就可以得到摇杆的移动方向和距离了。
更新:这里的
_line
计算出来后如果ratio
大于1,还要除以ratio
,不然暴露出去的line不是限制距离之后的结果。手机上不太好操作,明天再改。
private _line: cc.Vec2 = null;
private _angle: number = null;
/** 节点原位置与新位置的连线向量; */
get line(): cc.Vec2 {
return this._line;
}
/** 节点移动方向与x轴正方向的夹角,范围(-180, 180]。 */
get angle(): number {
return this._angle;
}
private onTouchEndOrCancel(): void {
switch (this.mode) {
case Mode.STICKY: {
const back = cc.moveTo(this.animDuration, this._positionWillBack);
this.node.runAction(back.easing(cc.easeBackOut()));
break;
} case Mode.ANYWHERE: {
// 手指离开屏幕,节点也就不再显示;
const back = cc.moveTo(this.animDuration, this._positionWillBack);
const fade = cc.fadeOut(this.animDuration)
this.node.runAction(cc.spawn(back, fade));
this._positionWillBack = null;
break;
}
}
// 记得置空变量;
this._line = null
this._angle = null
}
private updatePosition(position: cc.Vec2): void {
const newPosition = position;
if (this.mode != Mode.DRAG) {
// 这里修改了_line和_angle的值;
// _line就是节点原位置与新位置的连线向量;
this._line = newPosition.sub(this._positionWillBack);
this._angle = this._line.angle(cc.v2(1, 0)) * 180 / Math.PI;
if (this._line.y < 0) this._angle *= -1;
if (this.maxDistance && this.maxDistance > 0) {
const ratio = this._line.mag() / this.maxDistance;
// 为什么这么算,上面有图片解释;
if (ratio > 1) {
// 暴露出去的_line需要的是被限制范围后的;
this._line.divSelf(ratio);
newPosition.set(this._positionWillBack.add(this._line));
}
}
}
this.node.setPosition(newPosition);
}
这样,只要在游戏脚本内访问这两个get方法,就可以进行主角的移动了,不同项目对主角的操控可能不太一样,我这里就写一个最简单的:
@property(cc.FollowTouch)
director: FollowTouch = null;
// 游戏脚本内的update方法:
update(): void {
if (this.director.angle) {
this.player.setPosition(this.player.position.add(this.director.line));
}
}
完成
最后,我们可以将_positionWillBack
暴露出去,将它设置为一个与摇杆同级的节点的坐标,当此值为null
时隐藏它,就可以实现动态位置的摇杆背景了(这是针对Mode.ANYWHERE
模式的),
到这里,整个FollowTouch
类的编写便完成了,这是完成后的脚本:
const { ccclass, property } = cc._decorator;
enum Mode {
/** 直接拖动节点,手指离开屏幕时节点不会回到原来的位置; */
DRAG,
/** 节点跟随手指移动,手指离开屏幕时节点将回到原来的位置; */
STICKY,
/** 节点位置不固定,节点跟随手指移动,手指离开屏幕时节点不再可见; */
ANYWHERE,
}
@ccclass
export default class FollowTouch extends cc.Component {
@property(cc.Node)
container: cc.Node = null
@property({type: cc.Enum(Mode)})
mode: Mode = Mode.DRAG;
/** 此值为null时,不限制移动距离; */
@property({
type: cc.Integer,
min: -1,
})
maxDistance: number = -1
/** 用来设置节点回弹、显示或消失时的动画时长; */
@property({
type: cc.Integer,
min: 0
})
animDuration: number = 0;
private _positionWillBack: cc.Vec2 = null;
private _line: cc.Vec2 = null;
private _angle: number = null;
/** 节点原位置与新位置的连线向量; */
get line(): cc.Vec2 {
return this._line;
}
/** 节点移动方向与x轴正方向的夹角,范围(-180, 180]。 */
get angle(): number {
return this._angle;
}
get positionWillBack(): cc.Vec2 {
return this._positionWillBack;
}
onLoad(): void {
if (this.container == null) this.container = this.node.parent;
}
onEnable(): void {
if (this.mode == Mode.STICKY) this._positionWillBack = this.node.position;
this.subscribe();
}
onDisable(): void {
this.unsubscribe();
}
onDestroy(): void {
this.unsubscribe();
}
private subscribe(): void {
this.container.on(cc.Node.EventType.TOUCH_START, this.onTouchStart, this);
this.container.on(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.container.on(cc.Node.EventType.TOUCH_END, this.onTouchEndOrCancel, this);
this.container.on(cc.Node.EventType.TOUCH_CANCEL, this.onTouchEndOrCancel, this);
}
private unsubscribe(): void {
this.container.off(cc.Node.EventType.TOUCH_START, this.onTouchStart, this);
this.container.off(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.container.off(cc.Node.EventType.TOUCH_END, this.onTouchEndOrCancel, this);
this.container.off(cc.Node.EventType.TOUCH_CANCEL, this.onTouchEndOrCancel, this);
}
private onTouchStart(event: cc.Event.EventTouch): void {
// 触摸事件给予的坐标是世界坐标,我们将他转为节点局部空间内的坐标;
const position = this.node.parent.convertToNodeSpaceAR(event.getLocation());
// 在ANYWHERE模式下,节点默认不可见,所以现在要将它显示出来;
if (this.mode == Mode.ANYWHERE) {
this._positionWillBack = position;
this.node.runAction(cc.fadeIn(this.animDuration));
}
this.updatePosition(position);
}
private onTouchMove(event: cc.Event.EventTouch): void {
const newPosition = this.node.position.add(event.getDelta());
this.updatePosition(newPosition);
}
private onTouchEndOrCancel(): void {
switch (this.mode) {
case Mode.STICKY: {
const back = cc.moveTo(this.animDuration, this._positionWillBack);
this.node.runAction(back.easing(cc.easeBackOut()));
break;
}
case Mode.ANYWHERE: {
// 手指离开屏幕,节点也就不再显示;
const back = cc.moveTo(this.animDuration, this._positionWillBack);
const fade = cc.fadeOut(this.animDuration);
this.node.runAction(cc.spawn(back, fade));
this._positionWillBack = null;
break;
}
}
// 记得置空变量;
this._line = null;
this._angle = null;
}
private updatePosition(position: cc.Vec2): void {
const newPosition = position;
if (this.mode != Mode.DRAG) {
// 这里修改了_line和_angle的值;
// _line就是节点原位置与新位置的连线向量;
this._line = newPosition.sub(this._positionWillBack);
this._angle = this._line.angle(cc.v2(1, 0)) * 180 / Math.PI;
if (this._line.y < 0) this._angle *= -1;
if (this.maxDistance && this.maxDistance > 0) {
const ratio = this._line.mag() / this.maxDistance;
if (ratio > 1) {
// 暴露出去的_line需要的是被限制范围后的;
this._line.divSelf(ratio);
newPosition.set(this._positionWillBack.add(this._line));
}
}
}
this.node.setPosition(newPosition);
}
}
冲突处理
笔者还发现一个问题,在Mode.ANYWHERE
模式下,设置一个占用整个屏幕左侧的节点来作为摇杆的父级时,被这个节点区域覆盖的所有节点的触摸事件监听都失效了。
这是为什么呢?这就涉及到了Cocos Creator 的事件派发机制了,触摸事件会从被点击的最子级节点开始传递,一层一层地向上,直到到达根节点或者被强行截断。这点可以参考Cocos Creator 官方文档对事件派发机制的解释。
那该怎么办呢?很简单,一开始我就在FollowTouch
内声明了container
变量,变量留空时默认选择父级,也可以选择指定节点作为触发区域。我们直接将director(摇杆)移出这个directorRect(触发区域),然后将directorRect作为游戏场景的父节点,最后将director中FollowTouch
组件的container
属性设置为directorRect节点,如此便解决了。

效果

PS: 这里的Button控件是在游戏场景节点中的哦,也就是和player节点属于同级的。