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閱讀 227,428評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,024評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,285評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,548評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,328評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,878評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,971評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,098評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,616評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,554評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,725評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,243評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,971評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,361評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,613評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,339評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,695評論 2 370

推薦閱讀更多精彩內容