普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月24日首页

🕹️ 让你的Vue项目也能支持虚拟摇杆!一个Canvas虚拟摇杆组件让你的游戏体验飙升

作者 returnfalse
2025年10月24日 10:54

本期给大家分享一个虚拟摇杆的组件,当前版本是基于vue的,由于当年的我需要做线上抓娃娃的功能,之前的同事使用的上下左右四个图片箭头按钮来控制方向,就感觉这种实现效果体验感很不符合用户体验,用户每次点击才能发送一次移动方向消息,所以才去实现这么一个组件,通过游戏摇杆来控制消息发送

💡 组件介绍:一款能在vue项目中直接使用的虚拟摇杆,阅读代码就能替换资源修改自己想要的样式

今天给大家分享一个基于Canvas实现的虚拟摇杆组件,它不只是简单地显示一个可拖拽的圆圈,而是一个功能完整的游戏交互解决方案。它能够:

  • 实时响应:手指移动时立即反馈,无延迟
  • 方向识别:准确识别上、下、左、右及斜向方向
  • 距离计算:提供摇杆偏移距离,可用于控制移动速度
  • 角度测量:精确计算移动角度(0°~360°)
  • 优雅回弹:松开手指时自动回到中心位置

效果演示

9mh15-uhvsg.gif

🚀 实战演示:快速集成到你的项目

安装使用(其实就是复制粘贴)

把下面的代码保存为Joystick.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>

使用示例

<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优化性能

💖 如果你觉得这个组件好用...

点赞、收藏、分享给需要的朋友!如果你在使用过程中遇到问题,欢迎在评论区交流讨论。

记住,一个优秀的交互组件能让用户的游戏体验提升一个档次,而这个虚拟摇杆组件正是你游戏开发路上的得力助手!

💖 如果你还想知道如何使用同款组件在uni-app项目, 或者原生小程序项目中也能用这个丝滑的组件,可以评论留言,或者想使用我演示项目的图片资源,也可以留言

❌
❌