编写 Flutter 游戏摇杆组件
视频
前言
在这篇博客中,我们将手把手教您如何在Flutter中从零开发一个功能完整的虚拟摇杆控件。我们将从基础的触摸检测开始,逐步构建出一个支持多种交互效果的专业级组件。您将学习如何掌握Flutter的重要技术,包括手势检测、坐标系转换和状态管理,并探索虚拟摇杆在移动游戏、模拟器、AR/VR和机器人控制等实际应用场景中的潜力。快来一起开启这段有趣的开发之旅吧!
第一部分:基础理论与准备工作
1.1 数学基础知识
坐标系统
在 Flutter 中,我们使用的是屏幕坐标系:
- 原点(0,0):位于屏幕左上角
- X 轴:从左向右为正方向
- Y 轴:从上向下为正方向
但在游戏开发中,我们通常希望 Y 轴向上为正,这需要进行坐标转换。
坐标系统对比图解:
Flutter 屏幕坐标系 游戏坐标系 (0,0) ────────→ X Y ↑ │ │ │ │ │ └────────→ X ↓ Y (0,0) 转换公式:gameY = -screenY
核心数学公式
1. 距离计算公式
distance = √(x² + y²)
用于计算两点间的欧几里得距离。
图解说明:想象从摇杆中心点 O(0,0) 到摇杆球位置 P(x,y) 画一条直线,这条直线的长度就是距离。就像计算直角三角形的斜边长度一样。
2. 角度计算公式
angle = atan2(y, x) × (180 / π)
atan2
函数返回从 X 轴到点(x,y)的角度,范围为-π 到 π 弧度。
图解说明:角度是从正 X 轴(向右)开始,逆时针为正角度,顺时针为负角度。例如:
- 正右方:0°
- 正上方:-90°
- 正左方:-180°
- 正下方:90°
3. 坐标归一化公式
normalizedX = x / maxDistance
normalizedY = y / maxDistance
将坐标值转换为-1.0 到 1.0 的标准范围。
图解说明:归一化就是将实际像素距离转换为比例值。比如摇杆最大半径是 50 像素,当前位置是 25 像素,那么归一化值就是 25/50 = 0.5。
4. 边界限制公式
if (distance > maxDistance) {
x = cos(angle) × maxDistance
y = sin(angle) × maxDistance
}
确保摇杆不会超出允许的范围。
图解说明:当用户拖拽超出摇杆边界时,我们保持拖拽的方向(角度),但将距离限制在最大允许范围内。就像用绳子拴着一个球,球只能在绳长范围内移动。
1.2 技术基础
Flutter 手势检测
Flutter 提供了强大的手势检测系统:
- GestureDetector:用于检测各种手势
- onPanStart:开始拖拽时触发
- onPanUpdate:拖拽过程中持续触发
- onPanEnd:结束拖拽时触发
第二部分:虚拟摇杆实现
2.1 项目初始化
首先创建基本的 Flutter 项目结构:
// 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 主页面布局设计
// 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 数据显示面板
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 虚拟摇杆核心实现
/// 虚拟摇杆组件
/// 这是一个自定义的虚拟摇杆控件,可以检测用户的拖拽手势
/// 并将摇杆的位置转换为游戏中可用的数值
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 手势处理逻辑
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(() {});
}
手势处理流程图解:
用户触摸并拖拽 │ ↓ 获取触摸位置 (localPosition) │ ↓ 转换为相对中心的坐标 │ ↓ 计算距离中心的距离 │ ↓ 距离 ≤ 最大距离? │ │ 是│ │否 │ │ ↓ ↓ 直接使用 保持方向但 触摸位置 限制在边界 │ │ └────┬─────┘ │ ↓ 更新摇杆位置 │ ↓ 触发界面重绘
用户松手时,摇杆自动回弹到中心位置
void _handlePanEnd() {
// 回弹到中心位置
setState(() {
knobPosition = Offset.zero;
});
// 通知父组件摇杆已回到中心
widget.onChanged(0.0, 0.0, 0.0, 0.0);
}
技术关键点:
- 坐标变换:将屏幕坐标转换为摇杆相对坐标
- 边界检测:使用距离公式确保摇杆不超出允许范围
- 角度计算:使用
atan2
函数处理边界限制时的角度计算 - 状态同步:通过回调函数将摇杆状态传递给父组件
2.6 数值计算与转换
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 的比例值
数值转换示意图:
实际摇杆位置 归一化后的值 ┌─────────────────┐ ┌─────────────────┐ │ ● │ │ (-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 绘制实现
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 层级结构图解:
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 构造函数
const VirtualJoystick({
super.key,
required this.onChanged,
this.size = 120.0,
});
优化说明:使用 const 构造函数可以减少不必要的组件重建,提高性能。
避免频繁的 setState 调用
// 使用节流技术减少更新频率
Timer? _updateTimer;
void _throttledUpdate() {
_updateTimer?.cancel();
_updateTimer = Timer(const Duration(milliseconds: 16), () {
setState(() {});
});
}
性能提升:通过节流控制更新频率,避免过度重绘影响性能。
3.2 扩展功能实现
多摇杆支持
class DualJoystickGamepad extends StatefulWidget {
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
VirtualJoystick(onJoystickChanged: _updateLeftJoystick),
VirtualJoystick(onJoystickChanged: _updateRightJoystick),
],
);
}
}
按钮集成
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 实际应用场景
游戏控制
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);
}
}
通过触摸输入动态控制游戏角色的移动和旋转
机器人控制
void _handleRobotControl(double x, double y) {
// 差动驱动算法
final leftMotor = (y + x) * maxPower;
final rightMotor = (y - x) * maxPower;
// 发送控制指令
robotController.setMotorPower(leftMotor, rightMotor);
}
差动驱动算法是一种用于控制移动机器人的运动方式,尤其是在轮式机器人中。该算法通过操控两个独立驱动的轮子(通常位于机器人两侧)来实现转向和移动。
代码
完整代码
https://github.com/ducafecat/flutter_develop_tips/tree/main/flutter_application_gamepad
参考项目中的 lib/gamepad_easy.dart
和 lib/main.dart
文件。
小结
通过本课程,您已掌握了核心技术如Flutter手势检测、坐标转换和数学计算,以及完整的虚拟摇杆组件开发能力。这些技能使您能够在移动游戏、机器人控制、虚拟现实交互和工业控制系统等领域实现实际应用。
未来,您可以探索进阶方向,包括多摇杆支持、自定义样式和动画、复杂交互设计以及完整控制框架的构建。这将进一步提升您的开发能力,助您在Flutter的世界中开辟更广阔的前景。希望您能将所学应用于更具挑战性的项目中!
感谢阅读本文
如果有什么建议,请在评论中让我知道。我很乐意改进。
猫哥 APP
flutter 学习路径
- Flutter 优秀插件推荐
- Flutter 基础篇1 - Dart 语言学习
- Flutter 基础篇2 - 快速上手
- Flutter 实战1 - Getx Woo 电商APP
- Flutter 实战2 - 上架指南 Apple Store、Google Play
- Flutter 基础篇3 - 仿微信朋友圈
- Flutter 实战3 - 腾讯即时通讯 第一篇
- Flutter 实战4 - 腾讯即时通讯 第二篇
© 猫哥 ducafecat.com
end