编写 Flutter 游戏摇杆组件

编写 Flutter 游戏摇杆组件

视频

youtu.be/CXplqIVX02A

www.bilibili.com/video/BV1Zf...

前言

原文 Flutter游戏摇杆组件开发指南

在这篇博客中,我们将手把手教您如何在Flutter中从零开发一个功能完整的虚拟摇杆控件。我们将从基础的触摸检测开始,逐步构建出一个支持多种交互效果的专业级组件。您将学习如何掌握Flutter的重要技术,包括手势检测、坐标系转换和状态管理,并探索虚拟摇杆在移动游戏、模拟器、AR/VR和机器人控制等实际应用场景中的潜力。快来一起开启这段有趣的开发之旅吧!

第一部分:基础理论与准备工作

1.1 数学基础知识

坐标系统

在 Flutter 中,我们使用的是屏幕坐标系:

  • 原点(0,0):位于屏幕左上角
  • X 轴:从左向右为正方向
  • Y 轴:从上向下为正方向

但在游戏开发中,我们通常希望 Y 轴向上为正,这需要进行坐标转换。

坐标系统对比图解

ini 复制代码
Flutter 屏幕坐标系          游戏坐标系
(0,0) ────────→ X          Y ↑
  │                        │
  │                        │
  │                        └────────→ X
  ↓ Y                    (0,0)

转换公式:gameY = -screenY
核心数学公式

1. 距离计算公式

ini 复制代码
distance = √(x² + y²)

用于计算两点间的欧几里得距离。

图解说明:想象从摇杆中心点 O(0,0) 到摇杆球位置 P(x,y) 画一条直线,这条直线的长度就是距离。就像计算直角三角形的斜边长度一样。

2. 角度计算公式

scss 复制代码
angle = atan2(y, x) × (180 / π)

atan2函数返回从 X 轴到点(x,y)的角度,范围为-π 到 π 弧度。

图解说明:角度是从正 X 轴(向右)开始,逆时针为正角度,顺时针为负角度。例如:

  • 正右方:0°
  • 正上方:-90°
  • 正左方:-180°
  • 正下方:90°

3. 坐标归一化公式

ini 复制代码
normalizedX = x / maxDistance
normalizedY = y / maxDistance

将坐标值转换为-1.0 到 1.0 的标准范围。

图解说明:归一化就是将实际像素距离转换为比例值。比如摇杆最大半径是 50 像素,当前位置是 25 像素,那么归一化值就是 25/50 = 0.5。

4. 边界限制公式

scss 复制代码
if (distance > maxDistance) {
    x = cos(angle) × maxDistance
    y = sin(angle) × maxDistance
}

确保摇杆不会超出允许的范围。

图解说明:当用户拖拽超出摇杆边界时,我们保持拖拽的方向(角度),但将距离限制在最大允许范围内。就像用绳子拴着一个球,球只能在绳长范围内移动。

1.2 技术基础

Flutter 手势检测

Flutter 提供了强大的手势检测系统:

  • GestureDetector:用于检测各种手势
  • onPanStart:开始拖拽时触发
  • onPanUpdate:拖拽过程中持续触发
  • onPanEnd:结束拖拽时触发

第二部分:虚拟摇杆实现

2.1 项目初始化

首先创建基本的 Flutter 项目结构:

dart 复制代码
// main.dart - 应用入口
import 'package:flutter/material.dart';
import 'package:flutter_application_gamepad/gamepad_easy.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '虚拟摇杆',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const GamepadPage(),
    );
  }
}

技术说明

  • 使用Material3设计规范,提供现代化的 UI 体验
  • 设置蓝色主题色调,与摇杆控件保持一致性

2.2 主页面布局设计

dart 复制代码
// gamepad_easy.dart - 主页面
class GamepadPage extends StatefulWidget {
  const GamepadPage({super.key});

  @override
  State<GamepadPage> createState() => _GamepadPageState();
}

class _GamepadPageState extends State<GamepadPage> {
  // 摇杆状态变量
  double joystickX = 0.0;      // X轴位置值,范围 -1.0 到 1.0
  double joystickY = 0.0;      // Y轴位置值,范围 -1.0 到 1.0
  double joystickAngle = 0.0;  // 摇杆角度,范围 -180° 到 180°
  double joystickDistance = 0.0; // 距离中心的距离,范围 0.0 到 1.0

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('虚拟摇杆')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // 虚拟摇杆组件
            VirtualJoystick(
              onChanged: (x, y, angle, distance) {
                setState(() {
                  joystickX = x;
                  joystickY = y;
                  joystickAngle = angle;
                  joystickDistance = distance;
                });
              },
            ),

            const SizedBox(height: 40),

            // 数据显示区域
            _buildDataDisplayPanel(),
          ],
        ),
      ),
    );
  }
}

技术背景

  • 使用StatefulWidget管理摇杆状态
  • 通过回调函数实现子组件与父组件的通信
  • 采用setState方法触发 UI 更新

2.3 数据显示面板

dart 复制代码
Widget _buildDataDisplayPanel() {
  return Container(
    padding: const EdgeInsets.all(20),
    margin: const EdgeInsets.symmetric(horizontal: 20),
    decoration: BoxDecoration(
      color: Colors.grey[100],
      borderRadius: BorderRadius.circular(10),
      border: Border.all(color: Colors.grey[400]!),
    ),
    child: Column(
      children: [
        const Text(
          '摇杆数据',
          style: TextStyle(
            fontSize: 20,
            fontWeight: FontWeight.bold,
            color: Colors.black87,
          ),
        ),
        const SizedBox(height: 15),

        // 第一行:X轴和Y轴
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            _buildDataItem('X轴', joystickX, Colors.red),
            _buildDataItem('Y轴', joystickY, Colors.green),
          ],
        ),

        const SizedBox(height: 15),

        // 第二行:角度和距离
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            _buildDataItem('角度', joystickAngle, Colors.blue, '°'),
            _buildDataItem('距离', joystickDistance, Colors.orange),
          ],
        ),
      ],
    ),
  );
}

/// 构建数据显示项的辅助方法
/// [label] 显示的标签文本
/// [value] 要显示的数值
/// [color] 数值的颜色
/// [unit] 可选的单位符号(如 °)
Widget _buildDataItem(String label, double value, Color color, [String unit = '']) {
  return Column(
    children: [
      Text(
        label,
        style: const TextStyle(fontSize: 14, color: Colors.black54),
      ),
      const SizedBox(height: 5),
      Text(
        '${value.toStringAsFixed(2)}$unit',
        style: TextStyle(
          fontSize: 18,
          fontWeight: FontWeight.bold,
          color: color,
        ),
      ),
    ],
  );
}

设计理念

  • 使用颜色编码区分不同数据类型
  • 数值保留两位小数,提高可读性
  • 清晰的标签文字便于理解

2.4 虚拟摇杆核心实现

dart 复制代码
/// 虚拟摇杆组件
/// 这是一个自定义的虚拟摇杆控件,可以检测用户的拖拽手势
/// 并将摇杆的位置转换为游戏中可用的数值
class VirtualJoystick extends StatefulWidget {
  /// 当摇杆位置改变时的回调函数
  /// 参数:x(-1.0到1.0), y(-1.0到1.0), angle(-180到180度), distance(0.0到1.0)
  final Function(double x, double y, double angle, double distance) onChanged;

  /// 摇杆的大小(直径)
  final double size;

  const VirtualJoystick({
    super.key,
    required this.onChanged,
    this.size = 120.0,
  });

  @override
  State<VirtualJoystick> createState() => _VirtualJoystickState();
}

class _VirtualJoystickState extends State<VirtualJoystick> {
  /// 摇杆圆球的当前位置,相对于摇杆中心的偏移量
  /// Offset.zero 表示在正中心
  Offset knobPosition = Offset.zero;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: (details) {
        _handlePanUpdate(details);
      },
      onPanEnd: (details) {
        _handlePanEnd();
      },
      child: _buildJoystickUI(),
    );
  }
}

2.5 手势处理逻辑

dart 复制代码
void _handlePanUpdate(DragUpdateDetails details) {
  // 1. 计算摇杆中心点
  final center = widget.size / 2;

  // 2. 获取触摸点相对于摇杆中心的位置
  final position = details.localPosition - Offset(center, center);

  // 3. 计算触摸点到中心的距离
  final distance = position.distance;

  // 4. 设置最大允许距离(摇杆半径减去摇杆球半径)
  final maxDistance = center - 20; // 20是摇杆球的半径

  // 5. 边界检测和位置限制
  if (distance <= maxDistance) {
    // 在边界内,直接使用触摸位置
    knobPosition = position;
  } else {
    // 超出边界,将摇杆限制在边界上
    final angle = atan2(position.dy, position.dx);
    knobPosition = Offset(
      cos(angle) * maxDistance, // X坐标
      sin(angle) * maxDistance, // Y坐标
    );
  }

  // 6. 更新数值并重绘界面
  _updateValues();
  setState(() {});
}

手势处理流程图解

markdown 复制代码
用户触摸并拖拽
   │
   ↓
获取触摸位置 (localPosition)
   │
   ↓
转换为相对中心的坐标
   │
   ↓
计算距离中心的距离
   │
   ↓
距离 ≤ 最大距离?
│          │
是│          │否
│          │
↓          ↓
直接使用    保持方向但
触摸位置    限制在边界
│          │
└────┬─────┘
     │
     ↓
更新摇杆位置
     │
     ↓
触发界面重绘

用户松手时,摇杆自动回弹到中心位置

dart 复制代码
void _handlePanEnd() {
  // 回弹到中心位置
  setState(() {
  	knobPosition = Offset.zero;
  });
  
  // 通知父组件摇杆已回到中心
  widget.onChanged(0.0, 0.0, 0.0, 0.0);
}

技术关键点

  • 坐标变换:将屏幕坐标转换为摇杆相对坐标
  • 边界检测:使用距离公式确保摇杆不超出允许范围
  • 角度计算 :使用atan2函数处理边界限制时的角度计算
  • 状态同步:通过回调函数将摇杆状态传递给父组件

2.6 数值计算与转换

dart 复制代码
void _updateValues() {
  final maxDistance = widget.size / 2 - 20; // 最大距离

  // 计算归一化的X和Y值(范围:-1.0 到 1.0)
  final x = knobPosition.dx / maxDistance;
  final y = -knobPosition.dy / maxDistance; // Y轴反转(向上为正)

  // 计算角度(范围:-180° 到 180°)
  final angle = atan2(knobPosition.dy, knobPosition.dx) * 180 / pi;

  // 计算距离(范围:0.0 到 1.0)
  final distance = knobPosition.distance / maxDistance;

  // 通知父组件数值变化
  widget.onChanged(x, y, angle, distance);
}

数学原理详解

  • 归一化处理:将像素坐标转换为标准化的-1 到 1 范围,便于游戏逻辑处理
  • Y 轴反转:Flutter 的 Y 轴向下为正,游戏中通常 Y 轴向上为正,需要取负值
  • 角度转换:将弧度转换为度数,更符合日常理解习惯
  • 距离归一化:将实际像素距离转换为 0-1 的比例值

数值转换示意图

scss 复制代码
实际摇杆位置              归一化后的值
┌─────────────────┐      ┌─────────────────┐
│   ●             │      │  (-1,1)    (1,1)│
│ (30,40)像素     │ ──→  │    ●            │
│       ○ (0,0)   │      │  (-0.6,0.8)     │
│                 │      │       ○ (0,0)   │
│                 │      │  (-1,-1)   (1,-1)│
└─────────────────┘      └─────────────────┘

假设最大半径为 50 像素:
x = 30/50 = 0.6
y = -40/50 = -0.8 (Y轴反转)
distance = √(30²+40²)/50 = 50/50 = 1.0
angle = atan2(40,30) = 53.13°

2.7 UI 绘制实现

dart 复制代码
Widget _buildJoystickUI() {
  return Container(
    width: widget.size,
    height: widget.size,
    // 摇杆底座的样式
    decoration: BoxDecoration(
      shape: BoxShape.circle, // 圆形
      color: Colors.grey[300], // 浅灰色背景
      border: Border.all(color: Colors.grey[600]!, width: 2), // 深灰色边框
    ),
    child: Stack(
      children: [
        // 摇杆球(可移动的部分)
        Positioned(
          // 计算摇杆球的位置:中心位置 + 偏移量 - 球的半径
          left: (widget.size / 2) + knobPosition.dx - 20,
          top: (widget.size / 2) + knobPosition.dy - 20,
          child: Container(
            width: 40, // 摇杆球直径
            height: 40, // 摇杆球直径
            decoration: BoxDecoration(
              shape: BoxShape.circle, // 圆形
              color: Colors.grey[700], // 深灰色
              border: Border.all(
                color: Colors.grey[800]!,
                width: 2,
              ), // 更深的边框
            ),
          ),
        ),
      ],
    ),
  );
}

UI 设计说明

  • Stack 布局:使用 Stack 实现摇杆球在底座上的层叠效果
  • Positioned 定位:精确控制摇杆球的位置
  • 圆形设计 :使用BoxShape.circle创建完美的圆形外观
  • 颜色层次:通过不同灰度值创建视觉层次感

UI 层级结构图解

ini 复制代码
Container (摇杆底座)
├── 圆形背景 (Colors.grey[300])
├── 边框 (Colors.grey[600])
└── Stack
 └── Positioned (摇杆球)
     ├── 圆形背景 (Colors.grey[700])
     ├── 边框 (Colors.grey[800])
     └── 动态位置计算

位置计算公式:
left = (widget.size / 2) + knobPosition.dx - 20
top = (widget.size / 2) + knobPosition.dy - 20

其中:20 = 摇杆球半径

第三部分:实际应用与扩展

3.1 性能优化技巧

使用 const 构造函数
dart 复制代码
const VirtualJoystick({
  super.key,
  required this.onChanged,
  this.size = 120.0,
});

优化说明:使用 const 构造函数可以减少不必要的组件重建,提高性能。

避免频繁的 setState 调用
dart 复制代码
// 使用节流技术减少更新频率
Timer? _updateTimer;

void _throttledUpdate() {
  _updateTimer?.cancel();
  _updateTimer = Timer(const Duration(milliseconds: 16), () {
    setState(() {});
  });
}

性能提升:通过节流控制更新频率,避免过度重绘影响性能。

3.2 扩展功能实现

多摇杆支持
dart 复制代码
class DualJoystickGamepad extends StatefulWidget {
  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        VirtualJoystick(onJoystickChanged: _updateLeftJoystick),
        VirtualJoystick(onJoystickChanged: _updateRightJoystick),
      ],
    );
  }
}
按钮集成
dart 复制代码
Widget _buildActionButtons() {
  return Column(
    children: [
      Row(
        children: [
          _buildActionButton('A', Colors.green),
          _buildActionButton('B', Colors.red),
        ],
      ),
      Row(
        children: [
          _buildActionButton('X', Colors.blue),
          _buildActionButton('Y', Colors.yellow),
        ],
      ),
    ],
  );
}

3.3 实际应用场景

游戏控制
dart 复制代码
void _handleGameControl(double x, double y, double angle, double distance) {
  // 移动控制
  if (distance > 0.1) { // 死区设置
    final speed = distance * maxSpeed;
    final directionX = x * speed;
    final directionY = y * speed;

    // 更新游戏角色位置
    gameCharacter.move(directionX, directionY);
  }

  // 旋转控制
  if (distance > 0.5) {
    gameCharacter.rotate(angle);
  }
}

通过触摸输入动态控制游戏角色的移动和旋转

机器人控制
dart 复制代码
void _handleRobotControl(double x, double y) {
  // 差动驱动算法
  final leftMotor = (y + x) * maxPower;
  final rightMotor = (y - x) * maxPower;

  // 发送控制指令
  robotController.setMotorPower(leftMotor, rightMotor);
}

差动驱动算法是一种用于控制移动机器人的运动方式,尤其是在轮式机器人中。该算法通过操控两个独立驱动的轮子(通常位于机器人两侧)来实现转向和移动。


代码

完整代码

github.com/ducafecat/f...

参考项目中的 lib/gamepad_easy.dartlib/main.dart 文件。

小结

通过本课程,您已掌握了核心技术如Flutter手势检测、坐标转换和数学计算,以及完整的虚拟摇杆组件开发能力。这些技能使您能够在移动游戏、机器人控制、虚拟现实交互和工业控制系统等领域实现实际应用。

未来,您可以探索进阶方向,包括多摇杆支持、自定义样式和动画、复杂交互设计以及完整控制框架的构建。这将进一步提升您的开发能力,助您在Flutter的世界中开辟更广阔的前景。希望您能将所学应用于更具挑战性的项目中!

感谢阅读本文

如果有什么建议,请在评论中让我知道。我很乐意改进。


© 猫哥 ducafecat.com

end

相关推荐
Lanren的编程日记5 小时前
Flutter鸿蒙应用开发:生物识别(指纹/面容)功能集成实战
flutter·华为·harmonyos
Lanren的编程日记8 小时前
Flutter鸿蒙应用开发:基础UI组件库设计与实现实战
flutter·ui·harmonyos
西西学代码8 小时前
Flutter---波形动画
flutter
于慨12 小时前
flutter基础组件用法
开发语言·javascript·flutter
恋猫de小郭14 小时前
Android CLI ,谷歌为 Android 开发者专研的 AI Agent,提速三倍
android·前端·flutter
火柴就是我15 小时前
flutter pushAndRemoveUntil 的一次小疑惑
flutter
于慨15 小时前
flutter doctor问题解决
flutter
唔6615 小时前
flutter 图片加载类 图片的安全使用
安全·flutter
Nathan2024061617 小时前
Flutter - InheritedWidget
flutter·dart
恋猫de小郭17 小时前
JetBrains Amper 0.10 ,期待它未来替代 Gradle
android·前端·flutter