本期给大家分享一个虚拟摇杆的组件,当前版本是基于vue的,由于当年的我需要做线上抓娃娃的功能,之前的同事使用的上下左右四个图片箭头按钮来控制方向,就感觉这种实现效果体验感很不符合用户体验,用户每次点击才能发送一次移动方向消息,所以才去实现这么一个组件,通过游戏摇杆来控制消息发送
💡 组件介绍:一款能在vue项目中直接使用的虚拟摇杆,阅读代码就能替换资源修改自己想要的样式
今天给大家分享一个基于Canvas实现的虚拟摇杆组件,它不只是简单地显示一个可拖拽的圆圈,而是一个功能完整的游戏交互解决方案。它能够:
- 实时响应:手指移动时立即反馈,无延迟
- 方向识别:准确识别上、下、左、右及斜向方向
- 距离计算:提供摇杆偏移距离,可用于控制移动速度
- 角度测量:精确计算移动角度(0°~360°)
- 优雅回弹:松开手指时自动回到中心位置
效果演示

🚀 实战演示:快速集成到你的项目
安装使用(其实就是复制粘贴)
把下面的代码保存为Joystick.vue,放到你的组件目录:
vue
<template>
<div
style="position: relative"
:style="{ width: opts.josize + 'px', height: opts.josize + 'px' }"
>
<div
class="canvasBox"
style="width: 100%; height: 100%; bottom: 0; left: 0"
>
<canvas
class="moveCanvas"
:width="opts.josize"
:height="opts.josize"
:style="{ width: opts.josize + 'px', height: opts.josize + 'px' }"
></canvas>
</div>
<div
class="move-dom"
:class="{ active: opts.isStart }"
@touchstart="moverStart"
@touchmove="moveMove"
@touchend="moveEnd"
@touchcancel="moveEnd"
@mousemove="moveMove"
@mousedown="moverStart"
@mouseup="moveEnd"
></div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch, nextTick } from "vue";
import jPlayBg from "@/assets/images/j_play.png"; // 自动处理路径
import jBg from "@/assets/images/j.png"; // 自动处理路径
const opts = reactive<any>({
j_bg: "", // 摇杆背景
j_play_bg: "", // 摇杆按钮图片
isStart: false, // 是否触摸摇杆
top: 0, // 操作杆初始位置 top
left: 0, // 操作杆初始位置 left
jx: 0,
jy: 0,
josize: 140,
josize_bg: 120,
jisize: 75,
centerX: 75,
centerY: 75,
effectiveFinger: 0,
jc: null, // 画板
});
const props = withDefaults(
defineProps<{
bl: number;
isstart?: boolean;
}>(),
{
bl: 100,
isstart: true,
}
);
const emit = defineEmits<{
(e: "getObj", params: any): void;
}>();
watch(
() => props.isstart,
(val) => {
if (val) {
initFun();
}
},
{ immediate: true } // 可选:立即触发
);
watch(
() => opts.jx,
(val) => {
if (val) {
let distance = Math.ceil(
Math.sqrt(opts.jx * opts.jx + opts.jy * opts.jy)
);
// 判断方位信息
let obj = {
angle: "", // 方向
size: opts.josize_bg,
distance: distance, // 移动距离
degrees: getDegrees(opts.jx, opts.jy),
};
if (val > 0) {
// 操作杆在右上、右下
if (Math.abs(opts.jy) > Math.abs(opts.jx)) {
// 右边
if (opts.jy > 0) {
obj.angle = "down";
} else {
obj.angle = "up";
}
} else {
// 正右方
obj.angle = "right";
}
} else if (val <= 0) {
// 操作杆在左上、左下
if (Math.abs(opts.jy) > Math.abs(opts.jx)) {
// 左边
if (opts.jy > 0) {
obj.angle = "down";
} else {
obj.angle = "up";
}
} else {
// 正左方
obj.angle = "left";
}
}
throttle(emit("getObj", obj), 100);
}
}
);
// 角度转换
function getDegrees(x: number, y: number) {
// 1. 计算弧度
const radians = Math.atan2(y, x); // 结果范围:-π 到 π
// 2. 转换为角度(0°~360°)
let degrees = radians * (180 / Math.PI);
if (degrees < 0) degrees += 360; // 将负角度转为正角度
return degrees.toFixed(4);
}
// 节流函数
function throttle(func: any, wait: number): Function {
let timeout: ReturnType<typeof setTimeout> | null | undefined;
return function (
this: ThisParameterType<any>,
...args: Parameters<any>
): void {
timeout && clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(this, args);
}, wait);
};
}
// 初始化
async function initFun() {
// 确保DOM已挂载
await nextTick();
console.log("初始化");
opts.j_play_bg = await getImageAsync(jPlayBg);
opts.j_bg = await getImageAsync(jBg);
// 初始化尺寸
let size = Math.floor(props.bl);
opts.josize = size;
// 获取canvas ctx实例
if (!opts.jc) {
const canvas = document.querySelector(
".moveCanvas"
) as HTMLCanvasElement | null;
opts.jc = canvas?.getContext("2d");
}
// 初始化摇杆信息(摇杆背景,摇杆移动按钮,摇杆中心位置等)
await initCanvasRect();
requestAnimationFrame(move); //开始绘图
}
// 获取canvas的位置
async function initCanvasRect() {
const rect = await getElSize(".canvasBox");
opts.top = rect.top || 0;
opts.left = rect.left || 0;
opts.jisize = opts.josize * 0.35;
opts.josize_bg = opts.josize * 0.8;
opts.centerX = opts.josize / 2; //摇杆中心x坐标
opts.centerY = opts.josize / 2; //摇杆中心y坐标
return Promise.resolve();
}
// 开始绘制
//绘图函数(绘制图形的时候就是用户观察到摇杆动了,所以取名是move)
function move() {
opts.jc?.clearRect(
opts.centerX - opts.josize / 2,
opts.centerY - opts.josize / 2,
opts.josize,
opts.josize
); //清空画板
opts.jc?.drawImage(
opts.j_bg,
(opts.josize - opts.josize_bg) / 2,
(opts.josize - opts.josize_bg) / 2,
opts.josize_bg,
opts.josize_bg
); //画底座
opts.jc?.drawImage(
opts.j_play_bg,
opts.centerX - opts.jisize / 2 + opts.jx,
opts.centerY - opts.jisize / 2 + opts.jy,
opts.jisize,
opts.jisize
); //画摇杆头
requestAnimationFrame(move); //开始绘图
}
// 获取元素信息
function getElSize(el: any): Promise<any> {
return new Promise((resolve) => {
const element = document.querySelector(el);
const rect = element.getBoundingClientRect();
resolve(rect);
});
}
// 异步加载图片
function getImageAsync(url: string | undefined): Promise<any> {
return new Promise((resolve, reject) => {
if (!url) return reject();
let image = new Image();
image.src = url;
image.onload = () => {
resolve(image);
};
});
}
// 触摸开始
async function moverStart(event: MouseEvent | TouchEvent) {
event.preventDefault();
await initCanvasRect();
let cX =
"touches" in event
? event.touches[opts.effectiveFinger].clientX
: event.clientX;
let cY =
"touches" in event
? event.touches[opts.effectiveFinger].clientY
: event.clientY;
let clientX = cX - opts.left;
let clientY = cY - opts.top;
if (
clientX > 0 &&
clientX < opts.josize &&
clientY > 0 &&
clientY < opts.josize
) {
opts.isStart = true;
} else {
// 不符合条件
// console.log('不符合条件不能移动',clientX,clientY,opts.josize);
return;
}
//是否触摸点在摇杆上
if (
Math.sqrt(
Math.pow(clientX - opts.centerX, 2) + Math.pow(clientY - opts.centerY, 2)
) <=
opts.josize / 2 - opts.jisize / 2
) {
opts.jx = clientX - opts.centerX;
opts.jy = clientY - opts.centerY;
}
//否则计算摇杆最接近的位置
else {
var x = clientX,
y = clientY,
r = opts.josize / 2 - opts.jisize / 2;
var ans = getPoint(
opts.centerX,
opts.centerY,
r,
opts.centerX,
opts.centerY,
x,
y
);
//圆与直线有两个交点,计算出离手指最近的交点
if (
Math.sqrt((ans[0] - x) * (ans[0] - x) + (ans[1] - y) * (ans[1] - y)) <
Math.sqrt((ans[2] - x) * (ans[2] - x) + (ans[3] - y) * (ans[3] - y))
) {
opts.jx = ans[0] - opts.centerX;
opts.jy = ans[1] - opts.centerY;
} else {
opts.jx = ans[2] - opts.centerX;
opts.jy = ans[3] - opts.centerY;
}
}
}
// 移动中
function moveMove(event: TouchEvent | MouseEvent) {
if (!opts.isStart) {
// 首次触摸点未在操作杆上 停止运行
return;
}
let cX =
"touches" in event
? event.touches[opts.effectiveFinger].clientX
: event.clientX;
let cY =
"touches" in event
? event.touches[opts.effectiveFinger].clientY
: event.clientY;
let clientX = cX - opts.left;
let clientY = cY - opts.top;
//是否触摸点在摇杆上
if (
Math.sqrt(
Math.pow(clientX - opts.centerX, 2) + Math.pow(clientY - opts.centerY, 2)
) <=
opts.josize / 2 - opts.jisize / 2
) {
opts.jx = clientX - opts.centerX;
opts.jy = clientY - opts.centerY;
}
//否则计算摇杆最接近的位置
else {
var x = clientX,
y = clientY,
r = opts.josize / 2 - opts.jisize / 2;
var ans = getPoint(
opts.centerX,
opts.centerY,
r,
opts.centerX,
opts.centerY,
x,
y
);
//圆与直线有两个交点,计算出离手指最近的交点
if (
Math.sqrt((ans[0] - x) * (ans[0] - x) + (ans[1] - y) * (ans[1] - y)) <
Math.sqrt((ans[2] - x) * (ans[2] - x) + (ans[3] - y) * (ans[3] - y))
) {
opts.jx = ans[0] - opts.centerX;
opts.jy = ans[1] - opts.centerY;
} else {
opts.jx = ans[2] - opts.centerX;
opts.jy = ans[3] - opts.centerY;
}
}
}
// 触摸结束
function moveEnd() {
//若手指离开,那就把内摇杆放中间
opts.jx = 0;
opts.jy = 0;
opts.isStart = false;
emit("getObj", {
isStop: 1,
});
}
//计算圆于直线的交点
function getPoint(
cx: number,
cy: number,
r: number,
stx: number,
sty: number,
edx: number,
edy: number
) {
var k = (edy - sty) / (edx - stx); // 触碰位置 xy 与圆半径的差之后的比例 也就是圆心距离手指触碰y与x的比例
var b = edy - k * edx; // 手指触摸的位置 减去 比例 乘以手指触摸的x位置
var x1, y1, x2, y2; //定义坐标点
var c = cx * cx + (b - cy) * (b - cy) - r * r; // 圆心坐标相乘 加上
var a = 1 + k * k;
var b1 = 2 * cx - 2 * k * (b - cy);
var tmp = Math.sqrt(b1 * b1 - 4 * a * c);
x1 = (b1 + tmp) / (2 * a);
y1 = k * x1 + b;
x2 = (b1 - tmp) / (2 * a);
y2 = k * x2 + b;
return [x1, y1, x2, y2];
}
</script>
<style lang="scss" scoped>
.move-dom {
width: 100%;
height: 100%;
z-index: 100;
position: absolute;
top: 0;
left: 0;
}
.move-dom.active {
width: 100vw;
height: 100vh;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
使用示例
vue
<template>
<div class="game-container">
<!-- 其他游戏元素 -->
<div class="game-info">
<p>当前方向: {{ direction }}</p>
<p>移动距离: {{ distance }}</p>
<p>移动角度: {{ angle }}°</p>
</div>
<!-- 虚拟摇杆,放在屏幕左下角 -->
<div class="joystick-container">
<Joystick
:bl="120"
@getObj="handleJoystickData"
:isstart="true"
/>
</div>
</div>
</template>
<script setup>
import Joystick from '@/components/Joystick/Joystick.vue'
import { ref } from 'vue'
const direction = ref('停止')
const distance = ref(0)
const angle = ref(0)
const handleJoystickData = (data) => {
if(data.isStop) {
direction.value = '停止'
distance.value = 0
angle.value = 0
console.log('摇杆松开')
} else {
direction.value = data.angle
distance.value = data.distance
angle.value = data.degrees
console.log('摇杆数据:', data)
// 在这里可以发送数据给游戏逻辑
// 控制角色移动
moveCharacter(data.angle, data.distance)
}
}
// 游戏中的角色移动逻辑
const moveCharacter = (direction, distance) => {
// 根据方向和距离移动角色
console.log(`角色向${direction}方向移动,距离${distance}`)
}
</script>
<style scoped>
.game-container {
width: 100vw;
height: 100vh;
background: #000;
position: relative;
overflow: hidden;
}
.game-info {
position: absolute;
top: 20px;
left: 20px;
color: white;
z-index: 10;
}
.joystick-container {
position: absolute;
bottom: 20px;
left: 20px;
z-index: 10;
}
</style>
需要自行更换 import jPlayBg from "@/assets/images/j_play.png"; // 自动处理路径 import jBg from "@/assets/images/j.png"; // 自动处理路径 组件中这两个图片资源地址
🎯 应用场景:你的游戏开发利器
1. 🏎️ 赛车游戏
- 控制车辆转向
- 实时反馈方向盘角度
2. 🎮 射击游戏
- 控制角色移动
- 精确瞄准方向
3. 🤖 机器人遥控
- 遥控设备移动方向
- 实时位置反馈
4. 🎯 VR/AR应用
- 3D场景导航
- 视角控制
🌟 为什么选择这个组件?
- 性能优异:使用Canvas绘制,不占用DOM资源
- 易于集成:简单配置即可使用
- 功能完整:方向、距离、角度一应俱全
- 跨平台:同时支持触摸和鼠标操作
- 动画流畅:使用requestAnimationFrame优化性能
💖 如果你觉得这个组件好用...
点赞、收藏、分享给需要的朋友!如果你在使用过程中遇到问题,欢迎在评论区交流讨论。
记住,一个优秀的交互组件能让用户的游戏体验提升一个档次,而这个虚拟摇杆组件正是你游戏开发路上的得力助手!