单片机 GUI 设计(十九)- 摇杆
单片机 GUI 设计(十九)- 摇杆

单片机 GUI 设计(十九)- 摇杆

基于 GD32F303ZET6 苹果派开发板

简介

摇杆控件是一个输入控件,常常用于游戏开发,或者用于控制遥控车、无人机等设备,具体如下图所示。下图中,左右两个控件即为摇杆控件。

首先放一张结果图,本章中实现的摇杆效果如下所示。与上图不一样的是,本文中实现的摇杆控件,不管是背景区,还是滑块部分,都是带透明度的,这样显示的效果会更好一些。在单片机中,透明度叠加会带来算力上的巨大消耗,如果处理的的性能不够,也可以直接绘制实心圆,不考虑透明度。当然,如果资源充裕,也可以选择直接贴图。

控件设备结构体和 API 接口设计

为了方便用户移植和使用,我们使用一个结构体来管理摇杆控件,这样用户就可以创建任意多的摇杆控件在项目中,具体如下述代码所示。初始化时,用户需要提供控件的中心坐标、背景/滑块的半径、颜色以及透明度。至于后边的成员变量,例如当前角度、距离值等,都是运行过程中自动计算得出的。

为了驱动摇杆控件运行,需要为摇杆控件定义一个轮询函数,每隔一段时间扫描一下摇杆控件,更新控件显示。

//摇杆控制结构体
typedef struct
{
  unsigned int    x, y, rBg, rButton;    //中心坐标、背景半径、滑块半径
  unsigned int    bgColor, buttonColor;  //颜色
  unsigned char   bgAlpha, buttonAlpha;  //透明度
  unsigned int    curAngle;              //当前角度,角度制,以垂直向上为零,按顺时针方向增涨
  unsigned int    curDistance;           //当前滑块与控件中心的距离
  unsigned int    lastX, lastY;          //上一次按下的坐标
  unsigned short* buttonBg;              //按键的背景,用于按键的移动
  unsigned int    isPress;               //是否被按下标志位
}StructRockerDev;

//API  接口
void CreateRocker(StructRockerDev* widget);                                               //创建摇杆控件
void RockerPoll(StructRockerDev* widget);                                                 //轮询函数
int  GetRockResult(StructRockerDev* widget, unsigned int* angle, unsigned int* distance); //获取摇杆检测结果

源码中需要包含的头文件

摇杆驱动中,需要使用到三角函数、动态内存分配等接口,需要包含的头文件如下所示。

#include "Rocker.h"
#include "./LCD/LCD.h"
#include "math.h"
#include "malloc.h"
#include "stdio.h"

绘制透明的实心圆

摇杆驱动中,不论是背景还是滑块,都是一个透明的实心圆,透明实心圆的驱动代码如下所示。

绘制实心圆时,不能使用环形进度条中的驱动,因为 Bresenham 算法绘制实心圆时,会导致一些点被重复绘制,而透明度叠加具有累加效应,所以使用 Bresenham 算法会造成某些区域颜色更深。为了避免这种状况,我们采用了老方法,先计算实心圆坐标范围,然后扫描这篇区域内所有的点,计算这些点与圆心的距离。如果距离小于等于半径,那么就绘制该点。

/*********************************************************************************************************
* 函数名称: DrawPoint
* 函数功能: 画点
* 输入参数: x、y:坐标,color:颜色,alpha:透明度
* 输出参数: void
* 返 回 值: void
* 创建日期: 2023年03月21日
* 注    意: 只能用于 RGB565
*********************************************************************************************************/
static void DrawPoint(unsigned int x, unsigned int y, unsigned int color, unsigned int alpha)
{
  unsigned int foreColor, backColor;
  unsigned int backR, backG, backB, foreR, foreG, foreB, colorAlpha;
  unsigned int resultR, resultG, resultB;
  unsigned int result;

  //获取前景色
  foreColor = color;

  //获取背景色
  backColor = LCDReadPoint(x, y);

  //提前前景 RGB 通道数据
  foreR = ((foreColor >> 11) & 0x1F);
  foreG = ((foreColor >> 5) & 0x3F);
  foreB = ((foreColor >> 0) & 0x1F);

  //提前背景 RGB 通道数据
  backR = ((backColor >> 11) & 0x1F);
  backG = ((backColor >> 5) & 0x3F);
  backB = ((backColor >> 0) & 0x1F);

  //获取透明度
  colorAlpha = alpha;

  //RGB通道透明度叠加
  resultR = (foreR * colorAlpha + backR * (0xFF - colorAlpha)) >> 8;
  resultG = (foreG * colorAlpha + backG * (0xFF - colorAlpha)) >> 8;
  resultB = (foreB * colorAlpha + backB * (0xFF - colorAlpha)) >> 8;

  //组合成RGB565格式
  result = (((u16)resultR) << 11) | (((u16)resultG) << 5) | (((u16)resultB) << 0);

  //画点
  LCDFastDrawPoint(x, y, result);
}

/*********************************************************************************************************
* 函数名称: DrawCircle
* 函数功能: 绘制实心圆
* 输入参数: x,y:圆心坐标,r:半径,color:颜色,alpha:透明度
* 输出参数: void
* 返 回 值: void
* 创建日期: 2023年03月21日
* 注    意:
*********************************************************************************************************/
static void DrawCircle(unsigned int x, unsigned int y, unsigned int r, unsigned int color, unsigned int alpha)
{
  unsigned int xCir, yCir, x0, y0, x1, y1;
  double dx, dy, distance;

  //记录圆心坐标
  xCir = x;
  yCir = y;

  //计算显示区域大小
  x0 = xCir - r;
  x1 = xCir + r;
  y0 = yCir - r;
  y1 = yCir + r;

  //依次扫描所有的点
  for (y = y0; y <= y1; y++)
  {
    for (x = x0; x <= x1; x++)
    {
      //计算当前坐标与圆心的距离
      dx = abs(x - xCir);
      dy = abs(y - yCir);
      distance = sqrt(dx * dx + dy * dy);

      //距离圆心的距离小于等于半径,绘制该点
      if (distance <= r)
      {
        DrawPoint(x, y, color, alpha);
      }
    }
  }
}

更新滑块显示

在摇杆控件的设备结构体中,成员变量 buttonBg 表示滑块的背景缓冲区,创建摇杆控件时,背景缓冲区所需的内存由动态内存分配。

更新滑块显示时,首先要将当前滑块所在位置的背景填回去,即背景重绘,这样就可以擦除滑块所在区域。然后,在目的地绘制滑块之前,首先将该区域的像素点数据预先保存下来,为下次背景重绘做准备。最后才是绘制滑块,将滑块显示到屏幕上。经过这一系列复杂的操作后,才能将滑块从一个位置移动到另一个位置。具体实现代码如下所示。

/*********************************************************************************************************
* 函数名称: DrawButton
* 函数功能: 绘制滑块
* 输入参数: widget:控件首地址
*            x,y:新的坐标
* 输出参数: void
* 返 回 值: void
* 创建日期: 2023年03月21日
* 注    意:
*********************************************************************************************************/
static void DrawButton(StructRockerDev* widget, unsigned int x, unsigned int y)
{
  unsigned int x0, y0, x1, y1, i, targetX, targetY;

  //保存目标位置信息
  targetX = x;
  targetY = y;

  //新的坐标与旧的坐标相同
  if ((targetX == widget->lastX) && (targetY == widget->lastY))
  {
    return;
  }

  //计算背景所在位置
  x0 = widget->lastX - widget->rButton;
  x1 = widget->lastX + widget->rButton;
  y0 = widget->lastY - widget->rButton;
  y1 = widget->lastY + widget->rButton;

  //背景重绘
  i = 0;
  for (y = y0; y <= y1; y++)
  {
    for (x = x0; x <= x1; x++)
    {
      LCDFastDrawPoint(x, y, widget->buttonBg[i++]);
    }
  }

  //计算新的显示区域
  x0 = targetX - widget->rButton;
  x1 = targetX + widget->rButton;
  y0 = targetY - widget->rButton;
  y1 = targetY + widget->rButton;

  //保存背景
  i = 0;
  for (y = y0; y <= y1; y++)
  {
    for (x = x0; x <= x1; x++)
    {
      widget->buttonBg[i++] = LCDReadPoint(x, y);
    }
  }

  //绘制滑块
  DrawCircle(targetX, targetY, widget->rButton, widget->buttonColor, widget->buttonAlpha);

  //保存当前位置
  widget->lastX = targetX;
  widget->lastY = targetY;
}

创建函数

创建函数中,主要工作就是为滑块的背景缓冲区申请动态内存,具体如下所示。绘制出背景圆后,首先要将滑块背景像素点数据保存到背景缓冲区,为第一次背景重绘做准备,然后才能绘制滑块。

/*********************************************************************************************************
* 函数名称: CreateRocker
* 函数功能: 创建摇杆控件
* 输入参数: widget: 控件首地址
* 输出参数: void
* 返 回 值: void
* 创建日期: 2023年03月21日
* 注    意: 只能用于 RGB565
*********************************************************************************************************/
void CreateRocker(StructRockerDev* widget)
{
  unsigned int x, y, x0, y0, x1, y1, width, height, i;

  //画背景圆
  DrawCircle(widget->x, widget->y, widget->rBg, widget->bgColor, widget->bgAlpha);

  //计算滑块显示区域
  x0 = widget->x - widget->rButton;
  x1 = widget->x + widget->rButton;
  y0 = widget->y - widget->rButton;
  y1 = widget->y + widget->rButton;
  width = x1 - x0 + 1;
  height = y1 - y0 + 1;

  //为滑块背景申请动态内存
  widget->buttonBg = (unsigned short*)malloc(width * height * 2);
  if (NULL == widget->buttonBg)
  {
    printf("CreateRocker: Fail to malloc\r\n");
    while (1) {}
  }

  //保存滑块背景区域
  i = 0;
  for (y = y0; y <= y1; y++)
  {
    for (x = x0; x <= x1; x++)
    {
      widget->buttonBg[i++] = LCDReadPoint(x, y);
    }
  }

  //绘制滑块
  DrawCircle(widget->x, widget->y, widget->rButton, widget->buttonColor, widget->buttonAlpha);
  widget->lastX = widget->x;
  widget->lastY = widget->y;

  //标记尚未被按下
  widget->isPress = 0;
}

轮询函数

轮询函数中,只需要不停的调用 DrawButton 函数刷新滑块显示位置即可。绘制滑块时,有显示区域限制,不能绘制到背景圆外边,因此调用 DrawButton 函数之前,还得校验当前触点坐标是否越界,如果越界,则需要通过几何关系,计算出滑块在边界处的坐标。具体实现代码如下所示。

/*********************************************************************************************************
* 函数名称: RockerPoll
* 函数功能: 摇杆控件轮询函数
* 输入参数: widget: 控件首地址
* 输出参数: void
* 返 回 值: void
* 创建日期: 2023年03月21日
* 注    意: 只能用于 RGB565
*********************************************************************************************************/
void RockerPoll(StructRockerDev* widget)
{
  const double pi = 3.1415926535;
  unsigned int tx, ty, tflag;
  double x, y, dx, dy, distance, angle;

  //获取当前触屏坐标信息
  tflag = GetTouch(&tx, &ty);

  //未检测到按下
  if (0 == tflag)
  {
    widget->curAngle = 0;
    widget->curDistance = 0;
    DrawButton(widget, widget->x, widget->y);
    widget->isPress = 0;
    return;
  }

  //正好落在控件中心,滑块归位(定义落到原点上的角度值为零)
  if ((tx == widget->x) && (ty == widget->y))
  {
    widget->curAngle = 0;
    widget->curDistance = 0;
    DrawButton(widget, widget->x, widget->y);
    widget->isPress = 1;
    return;
  }

  //计算当前坐标值与控件中心的距离
  x = tx; y = ty;
  dx = x - widget->x;
  dy = y - widget->y;
  dy = -dy;
  distance = sqrt(dx * dx + dy * dy);

  //按照第一象限求角度
  angle = atan(fabs(dx) / fabs(dy));

  //角度补偿,补偿到 4 个象限
  if ((dx >= 0) && (dy >= 0)) //第一象限
  {
    angle = angle;
  }
  else if ((dx >= 0) && (dy <= 0)) //第二象限
  {
    angle = 0.5 * pi + (0.5 * pi - angle);
  }
  else if ((dx <= 0) && (dy <= 0)) //第三象限
  {
    angle = angle + pi;
  }
  else
  {
    angle = angle + pi + 2 * (0.5 * pi - angle);
  }

  //还在控件范围内
  if (distance <= widget->rBg)
  {
    //更新显示
    DrawButton(widget, tx, ty);

    //标记被按下
    widget->isPress = 1;

    //保存当前角度值和距离值
    widget->curAngle = 180.0 * angle / pi;
    widget->curDistance = distance;
  }

  //超出了控件范围,需要把坐标缩小到边界上
  else
  {
    //如果上一次检测时,控件有被按下
    if (0 != widget->isPress)
    {
      //计算目的坐标
      x = widget->x + widget->rBg * sin(angle);
      y = widget->y - widget->rBg * cos(angle);

      //更新显示
      DrawButton(widget, x, y);

      //保存当前角度值和距离值
      widget->curAngle = 180.0 * angle / pi;
      widget->curDistance = distance;
    }
  }
}

获取摇杆输入

其实,用户可以直接访问摇杆控制结构体获取当前角度、距离以及是否被按下等信息;但是为了规范性,在这里还是提供了一个 API 函数,专门用于获取摇杆输入,具体如下所示。

/*********************************************************************************************************
* 函数名称: GetRockResult
* 函数功能: 获取摇杆检测结果
* 输入参数: widget: 控件首地址
*            angle:用于输出当前角度,角度制,以垂直向上为零,顺时针方向为正方向
*            distance:手指与控件中心的距离
* 输出参数: void
* 返 回 值: 0-未检测到输入,其它-检测到输入
* 创建日期: 2023年03月21日
* 注    意: 只能用于 RGB565
*********************************************************************************************************/
int GetRockResult(StructRockerDev* widget, unsigned int* angle, unsigned int* distance)
{
  *angle = widget->curAngle;
  *distance = widget->curDistance;
  return widget->isPress;
}

测试代码

测试代码如下所示,为了方便显示结果,这里使用了两个 Text 控件,分别用于显示当前角度值和距离值。角度值范围是 0~359,为角度制,以垂直上升为零度,顺时针为正方向;距离值则是当前触点坐标与摇杆控件中心的距离值,以像素点为单位。

#include <stdio.h>
#include "./LCD/LCD.h"
#include "./Picture/BMP.h"
#include <windows.h>
#include "Rocker.h"
#include "Text.h"

int main(void)
{
  //相关变量
  static StructRockerDev s_structRocker;
  static StructText s_structText1;
  static StructText s_structText2;
  static char s_arrStringBuf[64];
  unsigned int angle, distance;

  //初始化
  InitLCD();
  InitBMP();

  //显示背景
  extern const unsigned char g_arrBmpBgImage800x480[768068];
  DrawBMP(g_arrBmpBgImage800x480, 0, 0);

  //创建摇杆控件
  s_structRocker.x = 400;
  s_structRocker.y = 240;
  s_structRocker.rBg = 100;
  s_structRocker.rButton = 25;
  s_structRocker.bgColor = LCD_COLOR_BLACK;
  s_structRocker.buttonColor = LCD_COLOR_YELLOW;
  s_structRocker.bgAlpha = 75;
  s_structRocker.buttonAlpha = 200;
  CreateRocker(&s_structRocker);

  //创建 Text1
  s_structText1.x = 10;
  s_structText1.y = 10;
  s_structText1.width = 12 * 20;
  s_structText1.size = 24;
  s_structText1.height = s_structText1.size;
  TextInit(&s_structText1);

  //创建 Text2
  s_structText2.x = 10;
  s_structText2.y = 40;
  s_structText2.width = 12 * 20;
  s_structText2.size = 24;
  s_structText2.height = s_structText2.size;
  TextInit(&s_structText2);
  
  //速度仪表盘显示
  while (1)
  {
    //摇杆轮询
    RockerPoll(&s_structRocker);

    //获取摇杆输入
    if (0 != GetRockResult(&s_structRocker, &angle, &distance))
    {
      //设置画笔颜色
      g_iLCDPointColor = LCD_COLOR_WHITE;

      //显示角度
      sprintf(s_arrStringBuf, "Angle: %d", angle);
      TextShow(&s_structText1, s_arrStringBuf);

      //显示距离
      sprintf(s_arrStringBuf, "Distance: %d", distance);
      TextShow(&s_structText2, s_arrStringBuf);
    }
    else
    {
      //设置画笔颜色
      g_iLCDPointColor = LCD_COLOR_WHITE;

      //显示角度
      sprintf(s_arrStringBuf, "Angle: --");
      TextShow(&s_structText1, s_arrStringBuf);

      //显示距离
      sprintf(s_arrStringBuf, "Distance: --");
      TextShow(&s_structText2, s_arrStringBuf);
    }

    //延时 25ms
    Sleep(25);
  }
  
  return 0;
}

源码

本章节中的源码请参考《单片机 GUI 设计(零)- 大纲