2D / 3D摇杆控制角色移动(原理讲解 + 源码分享)CocosCreator

2D / 3D摇杆控制角色移动(原理讲解 + 源码分享)CocosCreator

源码在末尾

前言

一年前我在Cocos论坛发了一篇封装2D摇杆的文章,因为对角色移动和转向这些逻辑都写在了摇杆脚本里面,有个小伙伴提出了宝贵的建议,我认为他说的很对,就重新整理下再加个3D版本的摇杆。


在这里插入图片描述

2D摇杆

效果


请添加图片描述

如何使用
节点的结构

在这里插入图片描述

吸取了上次的教训,这次分两个脚本实现2D摇杆
在这里插入图片描述

Joystick放在parent节点(摇杆背景和摇杆中心点父节点)上
Player放在角色上

Joystick.ts


import { _decorator, Component, Node, CCFloat, CCBoolean, Vec2, Vec3, math, log, Event, EventTouch, UITransformComponent, UITransform, CameraComponent } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('Main')
export default class Joystick extends Component {


    @property({displayName: "canvas下的相机,只拍UI的那个", tooltip: "canvas下的相机,只拍UI的那个", type: CameraComponent})
    camera: CameraComponent = null!;


    @property({displayName: "父节点", tooltip: "摇杆中心点和背景的父节点,需要用这个节点来做坐标转换", type: UITransformComponent})
    parent: UITransformComponent = null!;



    @property({displayName: "摇杆背景", tooltip: "摇杆背景", type: Node})
    bg: Node = null!;

    @property({displayName: "摇杆中心点", tooltip: "摇杆中心点", type: Node})
    joystick: Node = null!;



    @property({displayName: "最大半径", tooltip: "摇杆移动的最大半径", type: CCFloat})
    max_R: number = 135;


    

    @property({displayName: "是否禁用摇杆", tooltip: "是否禁用摇杆,禁用后摇杆将不能摇动"})
    is_forbidden: boolean = false;


    // 角色旋转的角度,不要轻易修改
    angle: number = 0;

    // 移动向量
    vector: Vec2 = new Vec2(0, 0);


    onLoad () {
        // 绑定事件
        // 因为摇杆中心点很小,如果给摇杆中心点绑定事件玩家将很难控制,摇杆的背景比较大,所以把事件都绑定在背景上是不错的选择,这样体验更好

        // 手指移动
        this.bg.on(Node.EventType.TOUCH_MOVE,this.move,this);
        // 手指结束
        this.bg.on(Node.EventType.TOUCH_END,this.finish,this);
        // 手指取消
        this.bg.on(Node.EventType.TOUCH_CANCEL,this.finish,this);
    }


    update () {
        
        // 如果角色的移动向量为(0, 0),就不执行以下代码
        if (this.vector.x == 0 && this.vector.y == 0) {
            return;
        }

        // 求出角色旋转的角度
        let angle = this.vector_to_angle(this.vector);
        // 赋值给angle,Player脚本将会获取angle
        this.angle = angle;

    }


    // 手指移动时调用,移动摇杆专用函数
    move (event: EventTouch) {

        // 如果没有禁用摇杆
        if(this.is_forbidden == false){

            /*
            通过点击屏幕获得的点的坐标是屏幕坐标
            必须先用相机从屏幕坐标转到世界坐标
            再从世界坐标转到节点坐标

            就这个问题折腾了很久
            踩坑踩坑踩坑
            */

            // 获取触点的位置,屏幕坐标
            let point = new Vec2(event.getLocationX(), event.getLocationY());
            // 屏幕坐标转为世界坐标
            let world_point = this.camera.screenToWorld(new Vec3(point.x, point.y));
            // 世界坐标转节点坐标
            // 将一个点转换到节点 (局部) 空间坐标系,这个坐标系以锚点为原点。
            let pos = this.parent.convertToNodeSpaceAR(new Vec3(world_point.x, world_point.y));

            // 如果触点长度小于我们规定好的最大半径
            if (pos.length() < this.max_R) {
                // 摇杆的坐标为触点坐标
                this.joystick.setPosition(pos.x, pos.y);
            } else {// 如果不

                // 将向量归一化
                let pos_ = pos.normalize();
                // 归一化的坐标 * 最大半径
                let x = pos_.x * this.max_R;
                let y = pos_.y * this.max_R;

                // 赋值给摇杆
                this.joystick.setPosition(x, y);
            }

            // 把摇杆中心点坐标,也就是角色移动向量赋值给vector
            this.vector = new Vec2(this.joystick.position.x, this.joystick.position.y);
        }
        // 如果摇杆被禁用 
        else {
            // 弹回摇杆
            this.finish();
        }


    }

    
    // 摇杆中心点弹回原位置专用函数
    finish () {
        // 摇杆坐标和移动向量都设为(0,0)
        this.joystick.position = new Vec3(0, 0);
        this.vector = new Vec2(0, 0);
    }



    // 角度转弧度
    angle_to_radian (angle: number): number {
        // 角度转弧度公式
        // π / 180 * 角度

        // 计算出弧度
        let radian = Math.PI / 180 * angle;
        // 返回弧度
        return(radian);
    }


    // 弧度转角度
    radian_to_angle (radian: number): number {
        // 弧度转角度公式
        // 180 / π * 弧度

        // 计算出角度
        let angle = 180 / Math.PI * radian;
        // 返回弧度
        return(angle);
    }


    // 角度转向量   
    angle_to_vector (angle: number): Vec2 {
        // tan = sin / cos
        // 将传入的角度转为弧度
        let radian = this.angle_to_radian(angle);
        // 算出cos,sin和tan
        let cos = Math.cos(radian);// 邻边 / 斜边
        let sin = Math.sin(radian);// 对边 / 斜边
        let tan = sin / cos;// 对边 / 邻边
        // 结合在一起并归一化
        let vec = new Vec2(cos, sin).normalize();
        // 返回向量
        return(vec);
    }


    // 向量转角度
    vector_to_angle (vector: Vec2): number {
        // 将传入的向量归一化
        let dir = vector.normalize();
        // 计算出目标角度的弧度
        let radian = dir.signAngle(new Vec2(1, 0));
        // 把弧度计算成角度
        let angle = -this.radian_to_angle(radian);
        // 返回角度
        return(angle);
    }

    
}

Player.ts


// 导入Joystick脚本
import joy from "./Joystick"

import { _decorator, Component, Node, CCLoader, CCFloat, Vec2, log } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('Player')
export class Player extends Component {


    @property({displayName: "摇杆脚本所在节点", tooltip: "摇杆脚本Joystick所在脚本", type: joy})
    joy: joy = null!;


    @property({displayName: "角色", tooltip: "角色", type: Node})
    player: Node = null!;


    @property({displayName: "是否根据方向旋转角色", tooltip: "角色是否根据摇杆的方向旋转"})
    is_angle: boolean = true;


    @property({displayName: "是否禁锢角色", tooltip: "是否禁锢角色,如果角色被禁锢,角色就动不了了"})
    is_fbd_player: boolean = false;


    @property({displayName: "角色移动速度", tooltip: "角色移动速度,不建议太大,1-10最好", type: CCFloat})
    speed: number = 3;


    // 角色的移动向量
    vector: Vec2 = new Vec2(0, 0);

    // 角色旋转的角度
    angle: number = 0;


    update () {
        // console.log("vector", this.vector.toString(), "angle", this.angle);
        

        // 如果没有禁锢角色
        if (this.is_fbd_player == false) {
            // 获取角色移动向量
            this.vector = this.joy.vector;

            // 向量归一化
            let dir = this.vector.normalize();

            // 乘速度
            let dir_x = dir.x * this.speed;
            let dir_y = dir.y * this.speed;

            // 角色坐标加上方向
            let x = this.player.position.x + dir_x;
            let y = this.player.position.y + dir_y;

            // 设置角色坐标
            this.player.setPosition(x, y);
        }

        // 如果根据方向旋转角色
        if (this.is_angle == true) {
            // 获取角色旋转的角度
            this.angle = this.joy.angle;
            // 对角色进行旋转
            this.player.angle = this.angle;
        }
        
    }


}

绑定好节点

在这里插入图片描述

在这里插入图片描述

原理讲解
每句代码我都写了非常详细的注释,属性也都加了中文的显示名称
请添加图片描述

先实现在规定范围内移动摇杆,都写在Joystick脚本里面
Joystick脚本里面封装了四个方法,分别是角度转弧度弧度转角度角度转向量向量转角度

详细的内容可以看我之前写的文章:三角函数在游戏中的应用

move方法就是用来移动摇杆中心点的,需要绑定在摇杆背景上,其实应该给摇杆中心点绑定事件,因为摇杆中心点很小,如果给摇杆中心点绑定事件玩家将很难控制,摇杆的背景比较大,所以把事件都绑定在背景上是不错的选择,这样体验更好


在这里插入图片描述

首先获取触点坐标,转化为parent节点局部空间坐标系
2.x的convertToNodeSpaceAR在Node下,而3.x的Node下就没有这个方法了,这个方法在UITransformComponent下
还有一个值得注意的点,2.x将触摸得到的点直接使用convertToNodeSpaceAR就可以完成转换,而3.x必须先用相机的screenToWorld方法把屏幕坐标转到世界坐标,然后再使用convertToNodeSpaceAR转到节点坐标(踩坑踩坑踩坑,这个问题我折腾了好几天)
我们只希望摇杆在规定好的max_R(最大半径)的范围内,不希望摇杆超出这个范围
所以要判定一下触点的坐标在不在规定的最大半径范围内,通过length获取触点坐标的长度,也就是触点距离原点的长度


在这里插入图片描述

比如我想求点A坐标的长度,求出的结果就是绿色线段的长度
并在最后设置好角色移动向量,Player脚本会获取vector来控制角色移动

finish方法是在结束移动摇杆的时候调用的,将摇杆位置弹回原处,并设置角色移动向量为(0, 0)


在这里插入图片描述

update里面求出角色旋转的角度,其实就是把vector向量转为角度


在这里插入图片描述

在Player脚本的update里面获取Joystick脚本的vector和angle,并根据需要设置角色的坐标和旋转角度
在这里插入图片描述

3D摇杆

效果

请添加图片描述

既然有了摇杆,就再加一个跳跃按钮和视角的移动吧
原理讲解
节点结构
在这里插入图片描述

一共有三个脚本

在这里插入图片描述

Joystick放在摇杆背景和摇杆中心点父节点上,Player放在角色上。UI放在canvas上,视角移动和跳跃相关逻辑写在这里
JoyStick和2D摇杆的Joystick区别不大,去掉了angle属性,因为3D摇杆不需要计算角色旋转 ,还去掉了封装的四个方法,分别是角度转弧度,弧度转角度,角度转向量和向量转角度,这些在3D摇杆里面都用不到了
2D摇杆是直接对角色坐标进行加减,3D摇杆中没有那么做,而是给角色加上了刚体和碰撞体,并且撸了一个地形
Player和2D摇杆的Player区别也不是很大,属性去掉了angle(角色旋转的角度)和is_angle(是否根据方向旋转角色),player的类型由Node改成了RigidBodyComponent,update全都不一样了
Player.ts中的update

    update () {
        // 如果没有禁锢角色
        if (this.is_fbd_player == false) {
            // 获取角色目标移动向量
            this.vector = this.joy.vector;
            // 归一化
            let dir = this.vector.normalize();
            // 乘速度
            let x = dir.x * this.speed;
            let y = dir.y * this.speed;
            // 获取角色当前移动向量
            let vc = new Vec3(0, 0, 0);
            this.player.getLinearVelocity(vc);
            // 结合成角色最终移动向量,因为摇杆获取的是Y轴,而最终设置的线性速度应该是Z轴,所以最后一个参数是负的
            let vec = new Vec3(x, vc.y, -y);

            // 向量四元数乘法
            Vec3.transformQuat(vec, vec, this.player.node.getRotation());

            // 设置角色移动向量
            this.player.setLinearVelocity(vec);
        }

    }

因为加了Z轴,而摇杆获取的vector的y是在二维下的,是Y轴向上X轴向右的结果。角色是三维的,是X轴向右Z轴向后的结果,所以结合角色最终移动向量的时候最后一个参数是-y


在这里插入图片描述

在这里插入图片描述

左右移动视角其实移动的是角色Y旋转,角色是主相机的父节点,所以相机也会跟着动,需要用到向量四元数乘法来根据角色朝向算出新的三维向量,最后设置线性速度

UI.ts

// 导入Player脚本
import player from "./Player";

import { _decorator, Component, Node, SystemEventType, EventMouse, Vec3, CCFloat, Vec2, EventTouch } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('UI')
export class UI extends Component {


    @property({displayName: "Player脚本所在节点", tooltip: "Player脚本所在节点", type: player})
    player: player = null!;


    @property({
        displayName: "移动视角事件目标节点", 
        tooltip: "代码将把移动视角的事件绑定到这个节点上,推荐把这个节点的宽高设置成和canvas一样,并给四个方向加上widget", 
        type: Node
    })
    target: Node = null!;

    @property({displayName: "相机", tooltip: "相机", type: Node})
    camera: Node = null!;


    @property({displayName: "相机移动速度", tooltip: "相机移动速度", type: CCFloat})
    angle_speed: number = 0.1;

    @property({displayName: "跳跃的高度", tooltip: "跳跃的高度,代码会根据这个值设置角色刚体的Y线性速度", type: CCFloat})
    jump_height: number = 5;

    @property({displayName: "跳跃按钮禁用时间", tooltip: "按一次跳跃按钮禁用多长时间,单位是秒", type: CCFloat})
    jump_btn_time: number = 1;


    @property({displayName: "相机上下移动限制", tooltip: "限制相机X旋转,X是向上移动限制的角度,Y是向下移动限制的角度"})
    cam_att: Vec2 = new Vec2(-25, -50);

    // 是否可以跳跃
    is_jump: boolean = true;


    onLoad () {
        let self = this;

        // 给canvas绑定触摸移动事件
        this.target.on(SystemEventType.TOUCH_MOVE, function (e: EventTouch) {
            
            // 获取鼠标距离上一次事件移动的距离对象,对象包含 x 和 y 属性
            let D = e.getDelta();

            
            // 上下左右移动视角
            // 左右移动视角是移动角色的Y旋转
            if (D.x < 0) {
                self.player.node.eulerAngles = self.player.node.eulerAngles.add3f(0, -D.x * self.angle_speed , 0);
            } else if (D.x > 0) {
                self.player.node.eulerAngles = self.player.node.eulerAngles.add3f(0, -D.x * self.angle_speed, 0);
            }

            // 上下移动视角是移动相机的X旋转
            if (D.y < 0) {
                self.camera.eulerAngles = self.camera.eulerAngles.add3f(D.y * self.angle_speed, 0, 0);
            } else if (D.y > 0) {
                self.camera.eulerAngles = self.camera.eulerAngles.add3f(D.y * self.angle_speed, 0, 0);
            }


            // 限制相机上下移动范围
            let angle = self.camera.eulerAngles;
            if (self.camera.eulerAngles.x > self.cam_att.x) {
                self.camera.eulerAngles = new Vec3(self.cam_att.x, angle.y, angle.z);
            }
            if (self.camera.eulerAngles.x < self.cam_att.y) {
                self.camera.eulerAngles = new Vec3(self.cam_att.y, angle.y, angle.z);
            }

        }, this);
    }


    // 跳跃按钮专用函数
    onbtn_jump () {
        if (this.is_jump == true) {
            // 获取角色移动向量
            let vc = new Vec3(0, 0, 0);
            this.player.player.getLinearVelocity(vc);
            // 设置角色Y的移动向量,让角色跳起来
            this.player.player.setLinearVelocity(new Vec3(vc.x, this.jump_height, vc.z));
            // console.log("点击了跳跃按钮");

            let self = this;
            // 不可以再次跳跃
            this.is_jump = false;
            // 规定时间后恢复跳跃
            this.scheduleOnce(function () {
                self.is_jump = true;
            }, this.jump_btn_time);
        }
    }

    
}

跳跃就是设置角色刚体线性速度的Y,其他的都不动
视角的上下移动因为没有相机弹簧,第三人称上下移动视角的时候会很奇怪很奇怪,所以加了视角上下移动的限制

想用相机弹簧可以去看白玉无冰大佬的文章
https://mp.weixin.qq.com/s/NCn8Ygk_I_nRnhmbHQeZwQ

2D摇杆源代码:https://gitee.com/propertygame/cocos-creator3.x-demos/tree/master/2DJoystick
3D摇杆源代码:https://gitee.com/propertygame/cocos-creator3.x-demos/tree/master/3DJoystick
技术交流Q群:1130122408
更多内容请关注微信公众号

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容