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
更多内容请关注微信公众号