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
更多內容請關注微信公眾號