Cocos Creator

Cocos Creator 小教程:创建一个虚拟摇杆

2020-03-21  本文已影响0人  7b6b1320bd2a

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.STICKYMode.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'点就是计算后的节点位置,用虚线圆代表最大移动距离。

草稿.jpg
由这个草图得知,原来的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节点,如此便解决了。

editor.jpg

效果

screenshot.gif

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

上一篇 下一篇

猜你喜欢

热点阅读