STM32G474 驱动 1.54 寸三色电子墨水屏实现贪吃蛇游戏完整指南

前言

电子墨水屏(E-Paper Display, EPD)以其超低功耗、阳光下可视、断电保持显示等独特优势,在电子标签、智能穿戴、电子书等领域得到了广泛应用。本文将详细介绍如何使用 STM32G474 微控制器驱动 1.54 寸三色(黑白红)电子墨水屏,并实现一个经典的贪吃蛇游戏。

本文将从硬件原理、软件架构、核心算法到完整代码实现进行全方位讲解,内容通俗易懂,适合嵌入式开发初学者和有一定经验的工程师参考。通过本文的学习,你将掌握电子墨水屏的驱动原理、SPI 通信协议、图形库设计以及游戏逻辑开发等多项技能。

一、项目概述

1.1 项目背景与意义

电子墨水屏技术自诞生以来,凭借其独特的显示特性,在低功耗显示领域占据了重要地位。与传统的 LCD 和 OLED 显示屏相比,电子墨水屏具有以下显著优势:

  • 超低功耗:仅在刷新屏幕时消耗电能,显示静态内容时几乎不耗电
  • 阳光下可视:反射式显示原理,在强光下依然清晰可见
  • 护眼效果好:无背光,光线柔和,长时间观看不易疲劳
  • 断电保持:断电后仍能保持显示内容不变

贪吃蛇游戏作为一款经典的休闲游戏,具有规则简单、趣味性强、代码量适中的特点,非常适合作为嵌入式系统的入门项目。将贪吃蛇游戏移植到电子墨水屏上,不仅可以展示电子墨水屏的显示效果,还能综合运用微控制器的各种外设和编程技巧。

1.2 功能介绍

本项目实现的贪吃蛇游戏具有以下功能:

  • 支持上下左右四个方向的蛇移动控制
  • 随机生成食物,蛇吃到食物后身体变长
  • 碰撞检测:检测蛇是否撞到墙壁或自己的身体
  • 游戏结束判断与重新开始功能
  • 分数显示功能
  • 游戏难度调节功能
  • 最高分记录功能

1.3 技术亮点

  • 三色显示:使用黑白红三色电子墨水屏,游戏界面更加美观
  • 低功耗设计:充分利用电子墨水屏的低功耗特性,适合电池供电
  • 模块化设计:软件采用分层架构,代码结构清晰,易于维护和扩展
  • 高效算法:优化的游戏逻辑算法,保证游戏运行流畅
  • 详细注释:代码中包含详细的中文注释,便于理解和学习

1.4 整体架构

本项目的整体架构如下图所示:

复制代码
┌─────────────────────────────────────────────────────────┐
│                      应用层                              │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │
│  │  游戏逻辑    │  │  按键处理    │  │  显示控制    │      │
│  └─────────────┘  └─────────────┘  └─────────────┘      │
├─────────────────────────────────────────────────────────┤
│                      服务层                              │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │
│  │   GUI库     │  │  定时器     │  │  数据存储    │      │
│  └─────────────┘  └─────────────┘  └─────────────┘      │
├─────────────────────────────────────────────────────────┤
│                      驱动层                              │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │
│  │ EPD驱动     │  │  SPI驱动    │  │  GPIO驱动   │      │
│  └─────────────┘  └─────────────┘  └─────────────┘      │
├─────────────────────────────────────────────────────────┤
│                      硬件层                              │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │
│  │ STM32G474   │  │ 电子墨水屏  │  │  按键矩阵   │      │
│  └─────────────┘  └─────────────┘  └─────────────┘      │
└─────────────────────────────────────────────────────────┘

二、硬件平台详解

2.1 STM32G474 微控制器介绍

STM32G474 是意法半导体(STMicroelectronics)推出的一款高性能 32 位微控制器,基于 ARM Cortex-M4 内核,最高工作频率可达 170MHz。该系列微控制器具有丰富的外设资源和出色的性能,非常适合工业控制、消费电子、物联网等领域的应用。

2.1.1 主要特性
特性 参数
内核 ARM Cortex-M4 32 位 RISC 内核,带 FPU 和 DSP 指令集
工作频率 最高 170MHz
Flash 存储器 512KB
SRAM 128KB
定时器 17 个定时器,包括高级定时器、通用定时器、基本定时器等
ADC 3 个 12 位 ADC,最多 40 个通道
DAC 4 个 12 位 DAC
通信接口 4 个 SPI、3 个 I2C、5 个 USART、2 个 CAN、1 个 USB 等
工作电压 1.71V ~ 3.6V
工作温度 -40℃ ~ 85℃
封装 LQFP64、LQFP100、LQFP144 等
2.1.2 本项目使用的外设

本项目主要使用了 STM32G474 的以下外设:

  • SPI 接口:用于与电子墨水屏通信
  • GPIO 接口:用于控制电子墨水屏的复位、数据 / 命令选择、片选等信号,以及按键输入
  • 定时器:用于产生游戏的定时中断,控制蛇的移动速度
  • 系统时钟:配置为 170MHz,提供系统运行时钟

2.2 1.54 寸三色电子墨水屏介绍

本项目使用的是淘宝采购的标准 1.54 寸三色电子墨水屏。该屏幕采用 SSD1680 驱动芯片,支持黑白红三色显示,分辨率为 152×152 像素。

2.2.1 主要特性
特性 参数
屏幕尺寸 1.54 英寸
分辨率 152 (H)×152 (V) 像素
显示颜色 黑白红三色
驱动芯片 SSD1680
接口 4 线 SPI 接口
工作电压 2.2V ~ 3.7V
工作温度 0℃ ~ 40℃
存储温度 -25℃ ~ 70℃
刷新时间 约 3 秒(25℃时)
功耗 刷新时约 20mA,待机时约 5μA
2.2.2 电子墨水屏显示原理

电子墨水屏的显示原理与传统的 LCD 和 OLED 显示屏完全不同。它利用了电泳现象,通过控制带电粒子的移动来实现显示。

电子墨水屏的每个像素点都包含一个微小的胶囊,胶囊内装有带正电的白色粒子、带负电的黑色粒子和带负电的红色粒子,悬浮在透明的液体中。当在像素点的上下电极施加不同极性的电压时,带电粒子会向不同的方向移动,从而在屏幕上显示出不同的颜色。

  • 当施加正电压时,带负电的黑色和红色粒子会移动到顶部,显示黑色或红色
  • 当施加负电压时,带正电的白色粒子会移动到顶部,显示白色

由于电子墨水屏是双稳态显示,一旦粒子移动到相应的位置,即使移除电压,粒子也会保持在原来的位置,因此断电后仍能保持显示内容不变。

2.2.3 SSD1680 驱动芯片介绍

SSD1680 是一款专为电子墨水屏设计的驱动芯片,集成了门驱动器、源驱动器、时序控制器、振荡器、DC-DC 转换器、SRAM、LUT 等功能模块。它支持多种分辨率的电子墨水屏,并且提供了丰富的命令集,方便用户进行控制。

SSD1680 的主要功能包括:

  • 支持 152×152 像素的显示分辨率
  • 内置 296×160 位的显示 RAM
  • 支持黑白红三色显示
  • 内置温度传感器,可根据温度自动调整显示波形
  • 支持多种 SPI 接口模式(3 线和 4 线)
  • 内置 DC-DC 转换器,可产生驱动电子墨水屏所需的各种电压
  • 支持深度睡眠模式,功耗极低

2.3 硬件连接与电路设计

2.3.1 引脚定义

电子墨水屏模块的引脚定义如下:

引脚号 引脚名称 类型 描述
1 GND P 电源地
2 VCC P 电源正极(3.3V)
3 SCL I SPI 时钟信号
4 SDA I/O SPI 数据信号
5 RES# I 复位信号,低电平有效
6 DC I 数据 / 命令选择信号,高电平为数据,低电平为命令
7 CS I 片选信号,低电平有效
8 BUSY O 忙信号,高电平表示芯片正在处理命令
2.3.2 与 STM32G474 的连接

本项目中,电子墨水屏与 STM32G474 的连接如下表所示:

电子墨水屏引脚 STM32G474 引脚 功能
GND GND 电源地
VCC 3.3V 电源正极
SCL PA5 SPI1_SCK
SDA PA7 SPI1_MOSI
RES# PB0 复位信号
DC PB1 数据 / 命令选择
CS PB2 片选信号
BUSY PB3 忙信号

按键与 STM32G474 的连接如下表所示:

按键功能 STM32G474 引脚 上拉 / 下拉
PC0 内部上拉
PC1 内部上拉
PC2 内部上拉
PC3 内部上拉
确认 / 重新开始 PC4 内部上拉
2.3.3 电路原理图

本项目的电路原理图非常简单,主要包括 STM32G474 最小系统、电子墨水屏接口和按键接口三部分。

STM32G474 最小系统包括电源电路、复位电路、晶振电路和下载电路。电子墨水屏直接通过 SPI 接口与 STM32G474 连接,按键通过 GPIO 口与 STM32G474 连接,并使用内部上拉电阻。

2.4 物料清单 (BOM)

本项目所需的物料清单如下:

序号 物料名称 型号 数量 备注
1 微控制器 STM32G474RET6 1 LQFP64 封装
2 电子墨水屏 ZJYE154S08R0G11 1 1.54 寸,三色,152×152 分辨率
3 按键 轻触开关 5 6×6×5mm
4 电阻 10kΩ 若干 可选,用于外部上拉
5 电容 100nF 若干 电源滤波
6 电容 10μF 2 电源滤波
7 晶振 8MHz 1 外部高速晶振
8 晶振 32.768kHz 1 外部低速晶振
9 排针 2.54mm 间距 若干 用于连接模块
10 排母 2.54mm 间距 若干 用于连接模块
11 PCB 板 定制 1 或使用面包板

三、软件架构设计

3.1 整体软件架构

本项目的软件采用分层架构设计,从下到上依次为硬件层、驱动层、服务层和应用层。这种架构设计具有以下优点:

  • 模块化:每个模块负责特定的功能,代码结构清晰
  • 可移植性:驱动层与硬件相关,服务层和应用层与硬件无关,便于移植到其他平台
  • 可维护性:模块之间的接口明确,修改一个模块不会影响其他模块
  • 可扩展性:可以方便地添加新的功能模块

3.2 模块划分

根据功能的不同,本项目的软件划分为以下几个模块:

  1. 主程序模块:负责系统初始化和主循环
  2. 电子墨水屏驱动模块:负责与电子墨水屏通信,实现基本的显示功能
  3. SPI 驱动模块:负责 SPI 接口的初始化和数据传输
  4. GPIO 驱动模块:负责 GPIO 口的初始化和控制
  5. 定时器驱动模块:负责定时器的初始化和中断处理
  6. GUI 图形库模块:提供基本的图形绘制功能,如点、线、矩形、圆、字符等
  7. 按键处理模块:负责按键的扫描和消抖,处理用户输入
  8. 游戏逻辑模块:实现贪吃蛇游戏的核心逻辑,包括蛇的移动、食物生成、碰撞检测等
  9. 数据存储模块:负责最高分等数据的存储和读取

3.3 主程序流程

主程序的流程如下:

  1. 系统初始化

    • 初始化系统时钟
    • 初始化 GPIO 口
    • 初始化 SPI 接口
    • 初始化定时器
    • 初始化电子墨水屏
    • 初始化游戏数据
  2. 显示游戏开始界面

    • 绘制游戏标题
    • 绘制操作说明
    • 绘制开始提示
  3. 等待用户按下开始键

  4. 游戏主循环

    • 扫描按键,处理用户输入
    • 更新游戏状态
    • 绘制游戏界面
    • 刷新电子墨水屏
    • 检测游戏是否结束
  5. 游戏结束

    • 显示游戏结束界面
    • 显示最终得分
    • 显示最高分
    • 等待用户按下重新开始键
  6. 返回步骤 4,重新开始游戏

四、电子墨水屏驱动原理与实现

4.1 电子墨水屏显示原理详解

如前所述,电子墨水屏利用电泳现象来实现显示。每个像素点包含一个微小的胶囊,胶囊内装有带正电的白色粒子、带负电的黑色粒子和带负电的红色粒子。

当在像素点的上下电极施加不同极性和大小的电压时,带电粒子会在电场的作用下向不同的方向移动。例如:

  • 当在上电极施加负电压,下电极施加正电压时,带正电的白色粒子会向上移动到上电极附近,此时该像素点显示白色
  • 当在上电极施加正电压,下电极施加负电压时,带负电的黑色和红色粒子会向上移动到上电极附近。此时,通过控制电压的大小和持续时间,可以控制黑色和红色粒子的比例,从而显示黑色或红色

电子墨水屏的刷新过程通常包括以下几个阶段:

  1. 复位阶段:对所有像素点施加相同的电压,将所有粒子复位到初始状态
  2. 擦除阶段:将整个屏幕刷新为白色
  3. 写入阶段:根据要显示的图像数据,对每个像素点施加相应的电压,将粒子移动到正确的位置
  4. 停止阶段:移除电压,粒子保持在当前位置,显示完成

由于电子墨水屏的刷新需要一定的时间(通常为几秒钟),因此在刷新过程中不能向驱动芯片发送任何命令,否则会导致显示异常。

4.2 SSD1680 驱动芯片命令集

SSD1680 驱动芯片提供了丰富的命令集,用于控制电子墨水屏的各种功能。以下是本项目中常用的一些命令:

表格

命令代码 命令名称 功能描述
0x01 Driver Output Control 设置门驱动器输出配置
0x0C Booster Soft Start Control 设置升压软启动控制
0x10 Deep Sleep Mode 进入深度睡眠模式
0x11 Data Entry Mode 设置数据输入模式
0x12 Software Reset 软件复位
0x18 Temperature Sensor Control 温度传感器控制
0x20 Master Activation 启动显示更新
0x21 Display Update Control 1 显示更新控制 1
0x22 Display Update Control 2 显示更新控制 2
0x24 Write RAM (Black/White) 写入黑白数据 RAM
0x26 Write RAM (Red) 写入红色数据 RAM
0x27 Read RAM 读取 RAM 数据
0x2A Write VCOM Register 写入 VCOM 寄存器
0x2B Write LUT Register 写入 LUT 寄存器
0x3C Border Waveform Control 边框波形控制
0x44 Set RAM X Address Start/End Position 设置 RAM X 地址起始和结束位置
0x45 Set RAM Y Address Start/End Position 设置 RAM Y 地址起始和结束位置
0x4E Set RAM X Address Counter 设置 RAM X 地址计数器
0x4F Set RAM Y Address Counter 设置 RAM Y 地址计数器

4.3 SPI 通信协议

SSD1680 驱动芯片支持 3 线和 4 线 SPI 接口模式。本项目使用 4 线 SPI 模式,包括 SCLK(时钟)、MOSI(主机输出从机输入)、DC(数据 / 命令选择)和 CS(片选)信号。

SPI 通信的时序如下:

  1. 当 CS 信号为低电平时,芯片被选中,可以进行通信
  2. DC 信号为低电平时,表示传输的是命令;DC 信号为高电平时,表示传输的是数据
  3. 在 SCLK 的上升沿,数据从 MOSI 引脚移位到芯片内部的移位寄存器
  4. 数据传输完成后,将 CS 信号拉高,结束通信

4.4 驱动代码实现

4.4.1 头文件定义 (EPD.h)

c

运行

复制代码
#ifndef __EPD_H
#define __EPD_H

#include "stm32g4xx_hal.h"
#include "spi.h"
#include "gpio.h"

// 电子墨水屏分辨率
#define EPD_WIDTH  152
#define EPD_HEIGHT 152

// 颜色定义
#define BLACK 0
#define WHITE 1
#define RED   2

// 电子墨水屏引脚定义
#define EPD_RES_PIN  GPIO_PIN_0
#define EPD_RES_PORT GPIOB
#define EPD_DC_PIN   GPIO_PIN_1
#define EPD_DC_PORT  GPIOB
#define EPD_CS_PIN   GPIO_PIN_2
#define EPD_CS_PORT  GPIOB
#define EPD_BUSY_PIN GPIO_PIN_3
#define EPD_BUSY_PORT GPIOB

// 宏定义
#define EPD_RES_LOW()  HAL_GPIO_WritePin(EPD_RES_PORT, EPD_RES_PIN, GPIO_PIN_RESET)
#define EPD_RES_HIGH() HAL_GPIO_WritePin(EPD_RES_PORT, EPD_RES_PIN, GPIO_PIN_SET)
#define EPD_DC_LOW()   HAL_GPIO_WritePin(EPD_DC_PORT, EPD_DC_PIN, GPIO_PIN_RESET)
#define EPD_DC_HIGH()  HAL_GPIO_WritePin(EPD_DC_PORT, EPD_DC_PIN, GPIO_PIN_SET)
#define EPD_CS_LOW()   HAL_GPIO_WritePin(EPD_CS_PORT, EPD_CS_PIN, GPIO_PIN_RESET)
#define EPD_CS_HIGH()  HAL_GPIO_WritePin(EPD_CS_PORT, EPD_CS_PIN, GPIO_PIN_SET)
#define EPD_IS_BUSY()  HAL_GPIO_ReadPin(EPD_BUSY_PORT, EPD_BUSY_PIN)

// 函数声明
void EPD_Init(void);
void EPD_Clear(void);
void EPD_Display(const uint8_t *black_image, const uint8_t *red_image);
void EPD_Sleep(void);
void EPD_WaitBusy(void);
void EPD_SendCommand(uint8_t command);
void EPD_SendData(uint8_t data);
void EPD_SetWindow(uint16_t x_start, uint16_t y_start, uint16_t x_end, uint16_t y_end);
void EPD_SetCursor(uint16_t x, uint16_t y);

#endif
4.4.2 驱动实现 (EPD.c)

c

运行

复制代码
#include "EPD.h"

// 电子墨水屏初始化代码
const uint8_t EPD_Init_Code[] = {
    0x01, 0x03, 0x97, 0x00, 0x00,  // Driver Output Control
    0x0C, 0x04, 0xAE, 0xC7, 0xC3, 0xC0,  // Booster Soft Start Control
    0x18, 0x01, 0x80,  // Temperature Sensor Control
    0x22, 0x01, 0xB1,  // Display Update Control 2
    0x20, 0x00,  // Master Activation
    0x11, 0x01, 0x03,  // Data Entry Mode
    0x44, 0x02, 0x00, 0x12,  // Set RAM X Address Start/End Position
    0x45, 0x04, 0x00, 0x00, 0x97, 0x00,  // Set RAM Y Address Start/End Position
    0x4E, 0x01, 0x00,  // Set RAM X Address Counter
    0x4F, 0x02, 0x00, 0x00,  // Set RAM Y Address Counter
    0x3C, 0x01, 0x01,  // Border Waveform Control
    0x21, 0x02, 0x00, 0x80,  // Display Update Control 1
};

/**
 * @brief  电子墨水屏初始化
 * @param  无
 * @retval 无
 */
void EPD_Init(void)
{
    uint8_t i = 0;
    
    // 硬件复位
    EPD_RES_HIGH();
    HAL_Delay(20);
    EPD_RES_LOW();
    HAL_Delay(2);
    EPD_RES_HIGH();
    HAL_Delay(20);
    
    // 等待忙信号
    EPD_WaitBusy();
    
    // 发送初始化命令
    while(i < sizeof(EPD_Init_Code))
    {
        EPD_SendCommand(EPD_Init_Code[i++]);
        for(uint8_t j = 0; j < EPD_Init_Code[i]; j++)
        {
            EPD_SendData(EPD_Init_Code[i + 1 + j]);
        }
        i += EPD_Init_Code[i] + 1;
    }
    
    // 等待忙信号
    EPD_WaitBusy();
}

/**
 * @brief  清屏
 * @param  无
 * @retval 无
 */
void EPD_Clear(void)
{
    uint16_t i, j;
    
    // 设置窗口
    EPD_SetWindow(0, 0, EPD_WIDTH - 1, EPD_HEIGHT - 1);
    EPD_SetCursor(0, 0);
    
    // 写入黑白数据
    EPD_SendCommand(0x24);
    for(i = 0; i < EPD_HEIGHT; i++)
    {
        for(j = 0; j < EPD_WIDTH / 8; j++)
        {
            EPD_SendData(0xFF);
        }
    }
    
    // 写入红色数据
    EPD_SendCommand(0x26);
    for(i = 0; i < EPD_HEIGHT; i++)
    {
        for(j = 0; j < EPD_WIDTH / 8; j++)
        {
            EPD_SendData(0x00);
        }
    }
    
    // 刷新屏幕
    EPD_SendCommand(0x22);
    EPD_SendData(0xC7);
    EPD_SendCommand(0x20);
    
    // 等待忙信号
    EPD_WaitBusy();
}

/**
 * @brief  显示图像
 * @param  black_image: 黑白图像数据
 * @param  red_image: 红色图像数据
 * @retval 无
 */
void EPD_Display(const uint8_t *black_image, const uint8_t *red_image)
{
    uint16_t i, j;
    
    // 设置窗口
    EPD_SetWindow(0, 0, EPD_WIDTH - 1, EPD_HEIGHT - 1);
    EPD_SetCursor(0, 0);
    
    // 写入黑白数据
    EPD_SendCommand(0x24);
    for(i = 0; i < EPD_HEIGHT; i++)
    {
        for(j = 0; j < EPD_WIDTH / 8; j++)
        {
            EPD_SendData(black_image[i * (EPD_WIDTH / 8) + j]);
        }
    }
    
    // 写入红色数据
    EPD_SendCommand(0x26);
    for(i = 0; i < EPD_HEIGHT; i++)
    {
        for(j = 0; j < EPD_WIDTH / 8; j++)
        {
            EPD_SendData(red_image[i * (EPD_WIDTH / 8) + j]);
        }
    }
    
    // 刷新屏幕
    EPD_SendCommand(0x22);
    EPD_SendData(0xC7);
    EPD_SendCommand(0x20);
    
    // 等待忙信号
    EPD_WaitBusy();
}

/**
 * @brief  进入深度睡眠模式
 * @param  无
 * @retval 无
 */
void EPD_Sleep(void)
{
    EPD_SendCommand(0x10);
    EPD_SendData(0x01);
    HAL_Delay(100);
}

/**
 * @brief  等待忙信号
 * @param  无
 * @retval 无
 */
void EPD_WaitBusy(void)
{
    while(EPD_IS_BUSY() == GPIO_PIN_SET)
    {
        HAL_Delay(10);
    }
}

/**
 * @brief  发送命令
 * @param  command: 要发送的命令
 * @retval 无
 */
void EPD_SendCommand(uint8_t command)
{
    EPD_DC_LOW();
    EPD_CS_LOW();
    HAL_SPI_Transmit(&hspi1, &command, 1, 100);
    EPD_CS_HIGH();
}

/**
 * @brief  发送数据
 * @param  data: 要发送的数据
 * @retval 无
 */
void EPD_SendData(uint8_t data)
{
    EPD_DC_HIGH();
    EPD_CS_LOW();
    HAL_SPI_Transmit(&hspi1, &data, 1, 100);
    EPD_CS_HIGH();
}

/**
 * @brief  设置显示窗口
 * @param  x_start: X起始坐标
 * @param  y_start: Y起始坐标
 * @param  x_end: X结束坐标
 * @param  y_end: Y结束坐标
 * @retval 无
 */
void EPD_SetWindow(uint16_t x_start, uint16_t y_start, uint16_t x_end, uint16_t y_end)
{
    EPD_SendCommand(0x44);
    EPD_SendData((x_start >> 3) & 0xFF);
    EPD_SendData((x_end >> 3) & 0xFF);
    
    EPD_SendCommand(0x45);
    EPD_SendData(y_start & 0xFF);
    EPD_SendData((y_start >> 8) & 0xFF);
    EPD_SendData(y_end & 0xFF);
    EPD_SendData((y_end >> 8) & 0xFF);
}

/**
 * @brief  设置光标位置
 * @param  x: X坐标
 * @param  y: Y坐标
 * @retval 无
 */
void EPD_SetCursor(uint16_t x, uint16_t y)
{
    EPD_SendCommand(0x4E);
    EPD_SendData((x >> 3) & 0xFF);
    
    EPD_SendCommand(0x4F);
    EPD_SendData(y & 0xFF);
    EPD_SendData((y >> 8) & 0xFF);
}

4.5 显示刷新机制

电子墨水屏的刷新是一个比较耗时的过程,通常需要几秒钟的时间。在刷新过程中,驱动芯片会根据 LUT(查找表)中的波形数据,对每个像素点施加相应的电压,将粒子移动到正确的位置。

LUT 是一个包含了各种电压波形数据的表格,它决定了电子墨水屏的刷新效果和速度。不同的电子墨水屏型号和不同的温度下,需要使用不同的 LUT 数据。

本项目中使用的 LUT 数据已经包含在初始化代码中,驱动芯片会根据内置的温度传感器自动调整 LUT 数据,以获得最佳的显示效果。

在刷新屏幕时,我们需要先将图像数据写入到驱动芯片的 RAM 中,然后发送显示更新命令,驱动芯片会自动完成刷新过程。在刷新过程中,我们需要等待忙信号变低,表示刷新完成。

五、GUI 图形库设计与实现

为了方便在电子墨水屏上绘制各种图形和文字,我们需要设计一个简单的 GUI 图形库。GUI 图形库提供了基本的图形绘制功能,如点、线、矩形、圆、字符等。

5.1 图形库架构

GUI 图形库采用双缓冲机制,即在内存中创建两个缓冲区,分别用于存储黑白图像数据和红色图像数据。所有的绘制操作都先在缓冲区中进行,绘制完成后再一次性将缓冲区的数据发送到电子墨水屏进行显示。

这种机制的优点是:

  • 提高了绘制速度,避免了频繁地向电子墨水屏发送数据
  • 减少了电子墨水屏的刷新次数,延长了屏幕的使用寿命
  • 可以实现复杂的图形效果

5.2 缓冲区定义

由于电子墨水屏的分辨率为 152×152 像素,每个像素点需要 1 位来表示黑白颜色,1 位来表示红色颜色。因此,我们需要两个缓冲区,每个缓冲区的大小为:

152 × 152 / 8 = 2888 字节

缓冲区的定义如下:

c

运行

复制代码
// 黑白图像缓冲区,1表示白色,0表示黑色
uint8_t EPD_Black_Buffer[EPD_WIDTH * EPD_HEIGHT / 8];
// 红色图像缓冲区,1表示红色,0表示非红色
uint8_t EPD_Red_Buffer[EPD_WIDTH * EPD_HEIGHT / 8];

5.3 基本图形绘制函数

5.3.1 绘制像素点

绘制像素点是所有图形绘制的基础。根据指定的坐标和颜色,在缓冲区中设置相应的位。

c

运行

复制代码
/**
 * @brief  绘制像素点
 * @param  x: X坐标
 * @param  y: Y坐标
 * @param  color: 颜色,可选BLACK、WHITE、RED
 * @retval 无
 */
void GUI_DrawPixel(uint16_t x, uint16_t y, uint16_t color)
{
    if(x >= EPD_WIDTH || y >= EPD_HEIGHT)
    {
        return;
    }
    
    uint16_t byte_index = y * (EPD_WIDTH / 8) + (x / 8);
    uint8_t bit_index = 7 - (x % 8);
    
    if(color == BLACK)
    {
        EPD_Black_Buffer[byte_index] &= ~(1 << bit_index);
        EPD_Red_Buffer[byte_index] &= ~(1 << bit_index);
    }
    else if(color == WHITE)
    {
        EPD_Black_Buffer[byte_index] |= (1 << bit_index);
        EPD_Red_Buffer[byte_index] &= ~(1 << bit_index);
    }
    else if(color == RED)
    {
        EPD_Black_Buffer[byte_index] |= (1 << bit_index);
        EPD_Red_Buffer[byte_index] |= (1 << bit_index);
    }
}
5.3.2 绘制直线

使用 Bresenham 算法绘制直线,该算法是一种高效的直线绘制算法,只使用整数运算,非常适合在嵌入式系统中使用。

c

运行

复制代码
/**
 * @brief  绘制直线
 * @param  x1: 起点X坐标
 * @param  y1: 起点Y坐标
 * @param  x2: 终点X坐标
 * @param  y2: 终点Y坐标
 * @param  color: 颜色
 * @retval 无
 */
void GUI_DrawLine(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color)
{
    int dx = abs(x2 - x1);
    int dy = abs(y2 - y1);
    int sx = x1 < x2 ? 1 : -1;
    int sy = y1 < y2 ? 1 : -1;
    int err = dx - dy;
    
    while(1)
    {
        GUI_DrawPixel(x1, y1, color);
        
        if(x1 == x2 && y1 == y2)
        {
            break;
        }
        
        int e2 = 2 * err;
        
        if(e2 > -dy)
        {
            err -= dy;
            x1 += sx;
        }
        
        if(e2 < dx)
        {
            err += dx;
            y1 += sy;
        }
    }
}
5.3.3 绘制矩形

绘制矩形可以通过绘制四条直线来实现,也可以通过填充矩形来实现。

c

运行

复制代码
/**
 * @brief  绘制矩形
 * @param  x1: 左上角X坐标
 * @param  y1: 左上角Y坐标
 * @param  x2: 右下角X坐标
 * @param  y2: 右下角Y坐标
 * @param  color: 颜色
 * @param  filled: 是否填充,0为空心,1为实心
 * @retval 无
 */
void GUI_DrawRectangle(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color, uint8_t filled)
{
    if(filled)
    {
        for(uint16_t y = y1; y <= y2; y++)
        {
            for(uint16_t x = x1; x <= x2; x++)
            {
                GUI_DrawPixel(x, y, color);
            }
        }
    }
    else
    {
        GUI_DrawLine(x1, y1, x2, y1, color);
        GUI_DrawLine(x1, y2, x2, y2, color);
        GUI_DrawLine(x1, y1, x1, y2, color);
        GUI_DrawLine(x2, y1, x2, y2, color);
    }
}
5.3.4 绘制圆

使用中点圆算法绘制圆,该算法也是一种高效的圆绘制算法,只使用整数运算。

c

运行

复制代码
/**
 * @brief  绘制圆
 * @param  x0: 圆心X坐标
 * @param  y0: 圆心Y坐标
 * @param  r: 半径
 * @param  color: 颜色
 * @param  filled: 是否填充,0为空心,1为实心
 * @retval 无
 */
void GUI_DrawCircle(uint16_t x0, uint16_t y0, uint16_t r, uint16_t color, uint8_t filled)
{
    int x = 0;
    int y = r;
    int d = 1 - r;
    
    while(x <= y)
    {
        if(filled)
        {
            for(int i = x0 - x; i <= x0 + x; i++)
            {
                GUI_DrawPixel(i, y0 + y, color);
                GUI_DrawPixel(i, y0 - y, color);
            }
            for(int i = x0 - y; i <= x0 + y; i++)
            {
                GUI_DrawPixel(i, y0 + x, color);
                GUI_DrawPixel(i, y0 - x, color);
            }
        }
        else
        {
            GUI_DrawPixel(x0 + x, y0 + y, color);
            GUI_DrawPixel(x0 - x, y0 + y, color);
            GUI_DrawPixel(x0 + x, y0 - y, color);
            GUI_DrawPixel(x0 - x, y0 - y, color);
            GUI_DrawPixel(x0 + y, y0 + x, color);
            GUI_DrawPixel(x0 - y, y0 + x, color);
            GUI_DrawPixel(x0 + y, y0 - x, color);
            GUI_DrawPixel(x0 - y, y0 - x, color);
        }
        
        if(d < 0)
        {
            d += 2 * x + 3;
        }
        else
        {
            d += 2 * (x - y) + 5;
            y--;
        }
        x++;
    }
}

5.4 字符与汉字显示

为了在电子墨水屏上显示字符和汉字,我们需要使用点阵字库。本项目使用了 8x6、12x6、16x8、24x12 和 48x24 五种大小的 ASCII 字符字库,以及 12x12、16x16、24x24 和 32x32 四种大小的汉字字库。

5.4.1 字符显示

字符显示函数根据指定的坐标、字符、字号和颜色,在缓冲区中绘制相应的字符。

c

运行

复制代码
/**
 * @brief  显示单个字符
 * @param  x: X坐标
 * @param  y: Y坐标
 * @param  chr: 要显示的字符
 * @param  size: 字号,可选8、12、16、24、48
 * @param  color: 颜色
 * @retval 无
 */
void GUI_DrawChar(uint16_t x, uint16_t y, uint8_t chr, uint8_t size, uint16_t color)
{
    uint8_t i, j;
    uint8_t temp;
    uint8_t csize = (size / 8 + ((size % 8) ? 1 : 0)) * (size / 2);
    uint8_t *font;
    
    chr = chr - ' '; // 计算偏移量
    
    switch(size)
    {
        case 8:
            font = (uint8_t *)asc2_0806[chr];
            break;
        case 12:
            font = (uint8_t *)asc2_1206[chr];
            break;
        case 16:
            font = (uint8_t *)asc2_1608[chr];
            break;
        case 24:
            font = (uint8_t *)asc2_2412[chr];
            break;
        case 48:
            font = (uint8_t *)asc2_4824[chr];
            break;
        default:
            return;
    }
    
    for(i = 0; i < csize; i++)
    {
        temp = font[i];
        for(j = 0; j < 8; j++)
        {
            if(temp & (0x80 >> j))
            {
                GUI_DrawPixel(x + j, y + i, color);
            }
        }
        if((i + 1) % (size / 8) == 0)
        {
            x += 8;
        }
    }
}

/**
 * @brief  显示字符串
 * @param  x: X坐标
 * @param  y: Y坐标
 * @param  str: 要显示的字符串
 * @param  size: 字号
 * @param  color: 颜色
 * @retval 无
 */
void GUI_DrawString(uint16_t x, uint16_t y, uint8_t *str, uint8_t size, uint16_t color)
{
    while(*str != '\0')
    {
        GUI_DrawChar(x, y, *str, size, color);
        x += size / 2;
        str++;
    }
}
5.4.2 汉字显示

汉字显示函数与字符显示函数类似,只是使用的字库不同。

c

运行

复制代码
/**
 * @brief  显示单个汉字
 * @param  x: X坐标
 * @param  y: Y坐标
 * @param  ch: 要显示的汉字
 * @param  size: 字号,可选12、16、24、32
 * @param  color: 颜色
 * @retval 无
 */
void GUI_DrawChinese(uint16_t x, uint16_t y, uint8_t *ch, uint8_t size, uint16_t color)
{
    uint8_t i, j;
    uint16_t k;
    uint16_t hz_num;
    uint16_t typeface_num;
    uint16_t x0 = x;
    
    typeface_num = (size / 8 + ((size % 8) ? 1 : 0)) * size;
    
    switch(size)
    {
        case 12:
            hz_num = sizeof(tfont12) / sizeof(typFNT_GB12);
            for(k = 0; k < hz_num; k++)
            {
                if((tfont12[k].Index[0] == ch[0]) && (tfont12[k].Index[1] == ch[1]))
                {
                    for(i = 0; i < typeface_num; i++)
                    {
                        for(j = 0; j < 8; j++)
                        {
                            if(tfont12[k].Msk[i] & (0x01 << j))
                            {
                                GUI_DrawPixel(x, y, color);
                            }
                            x++;
                            if((x - x0) == size)
                            {
                                x = x0;
                                y++;
                                break;
                            }
                        }
                    }
                    break;
                }
            }
            break;
        case 16:
            hz_num = sizeof(tfont16) / sizeof(typFNT_GB16);
            for(k = 0; k < hz_num; k++)
            {
                if((tfont16[k].Index[0] == ch[0]) && (tfont16[k].Index[1] == ch[1]))
                {
                    for(i = 0; i < typeface_num; i++)
                    {
                        for(j = 0; j < 8; j++)
                        {
                            if(tfont16[k].Msk[i] & (0x01 << j))
                            {
                                GUI_DrawPixel(x, y, color);
                            }
                            x++;
                            if((x - x0) == size)
                            {
                                x = x0;
                                y++;
                                break;
                            }
                        }
                    }
                    break;
                }
            }
            break;
        case 24:
            hz_num = sizeof(tfont24) / sizeof(typFNT_GB24);
            for(k = 0; k < hz_num; k++)
            {
                if((tfont24[k].Index[0] == ch[0]) && (tfont24[k].Index[1] == ch[1]))
                {
                    for(i = 0; i < typeface_num; i++)
                    {
                        for(j = 0; j < 8; j++)
                        {
                            if(tfont24[k].Msk[i] & (0x01 << j))
                            {
                                GUI_DrawPixel(x, y, color);
                            }
                            x++;
                            if((x - x0) == size)
                            {
                                x = x0;
                                y++;
                                break;
                            }
                        }
                    }
                    break;
                }
            }
            break;
        case 32:
            hz_num = sizeof(tfont32) / sizeof(typFNT_GB32);
            for(k = 0; k < hz_num; k++)
            {
                if((tfont32[k].Index[0] == ch[0]) && (tfont32[k].Index[1] == ch[1]))
                {
                    for(i = 0; i < typeface_num; i++)
                    {
                        for(j = 0; j < 8; j++)
                        {
                            if(tfont32[k].Msk[i] & (0x01 << j))
                            {
                                GUI_DrawPixel(x, y, color);
                            }
                            x++;
                            if((x - x0) == size)
                            {
                                x = x0;
                                y++;
                                break;
                            }
                        }
                    }
                    break;
                }
            }
            break;
        default:
            return;
    }
}

/**
 * @brief  显示汉字串
 * @param  x: X坐标
 * @param  y: Y坐标
 * @param  str: 要显示的汉字串
 * @param  size: 字号
 * @param  color: 颜色
 * @retval 无
 */
void GUI_DrawChineseString(uint16_t x, uint16_t y, uint8_t *str, uint8_t size, uint16_t color)
{
    while(*str != '\0')
    {
        GUI_DrawChinese(x, y, str, size, color);
        str += 2;
        x += size;
    }
}

5.5 数字显示

数字显示函数用于显示整数和浮点数,方便显示分数、时间等信息。

c

运行

复制代码
/**
 * @brief  显示整数
 * @param  x: X坐标
 * @param  y: Y坐标
 * @param  num: 要显示的整数
 * @param  len: 数字的位数
 * @param  size: 字号
 * @param  color: 颜色
 * @retval 无
 */
void GUI_DrawNum(uint16_t x, uint16_t y, uint32_t num, uint8_t len, uint8_t size, uint16_t color)
{
    uint8_t t, temp;
    uint8_t m = 0;
    
    if(size == 8)
    {
        m = 2;
    }
    
    for(t = 0; t < len; t++)
    {
        temp = (num / GUI_Pow(10, len - t - 1)) % 10;
        GUI_DrawChar(x + (size / 2 + m) * t, y, temp + '0', size, color);
    }
}

/**
 * @brief  显示浮点数
 * @param  x: X坐标
 * @param  y: Y坐标
 * @param  num: 要显示的浮点数
 * @param  len: 数字的总位数
 * @param  pre: 小数位数
 * @param  size: 字号
 * @param  color: 颜色
 * @retval 无
 */
void GUI_DrawFloatNum(uint16_t x, uint16_t y, float num, uint8_t len, uint8_t pre, uint8_t size, uint16_t color)
{
    uint8_t t, temp;
    uint8_t sizex = size / 2;
    uint32_t num1;
    
    num1 = num * GUI_Pow(10, pre);
    
    for(t = 0; t < len; t++)
    {
        temp = (num1 / GUI_Pow(10, len - t - 1)) % 10;
        if(t == (len - pre))
        {
            GUI_DrawChar(x + (len - pre) * sizex, y, '.', size, color);
            t++;
            len += 1;
        }
        GUI_DrawChar(x + t * sizex, y, temp + '0', size, color);
    }
}

/**
 * @brief  指数运算
 * @param  m: 底数
 * @param  n: 指数
 * @retval m的n次方
 */
uint32_t GUI_Pow(uint8_t m, uint8_t n)
{
    uint32_t result = 1;
    while(n--)
    {
        result *= m;
    }
    return result;
}

5.6 图片显示

图片显示函数用于显示位图图片,图片数据需要预先转换为 C 语言数组格式。

c

运行

复制代码
/**
 * @brief  显示图片
 * @param  x: X坐标
 * @param  y: Y坐标
 * @param  width: 图片宽度
 * @param  height: 图片高度
 * @param  bmp: 图片数据
 * @param  color: 颜色
 * @retval 无
 */
void GUI_DrawPicture(uint16_t x, uint16_t y, uint16_t width, uint16_t height, const uint8_t *bmp, uint16_t color)
{
    uint16_t i, j;
    uint16_t byte_width = (width + 7) / 8;
    uint8_t temp;
    
    for(i = 0; i < height; i++)
    {
        for(j = 0; j < byte_width; j++)
        {
            temp = bmp[i * byte_width + j];
            for(uint8_t k = 0; k < 8; k++)
            {
                if(temp & (0x80 >> k))
                {
                    GUI_DrawPixel(x + j * 8 + k, y + i, color);
                }
            }
        }
    }
}

5.7 缓冲区操作函数

为了方便操作缓冲区,我们还需要提供一些缓冲区操作函数,如清空缓冲区、刷新缓冲区等。

c

运行

复制代码
/**
 * @brief  清空缓冲区
 * @param  color: 填充颜色
 * @retval 无
 */
void GUI_Clear(uint16_t color)
{
    uint16_t i;
    
    if(color == BLACK)
    {
        for(i = 0; i < sizeof(EPD_Black_Buffer); i++)
        {
            EPD_Black_Buffer[i] = 0x00;
            EPD_Red_Buffer[i] = 0x00;
        }
    }
    else if(color == WHITE)
    {
        for(i = 0; i < sizeof(EPD_Black_Buffer); i++)
        {
            EPD_Black_Buffer[i] = 0xFF;
            EPD_Red_Buffer[i] = 0x00;
        }
    }
    else if(color == RED)
    {
        for(i = 0; i < sizeof(EPD_Black_Buffer); i++)
        {
            EPD_Black_Buffer[i] = 0xFF;
            EPD_Red_Buffer[i] = 0xFF;
        }
    }
}

/**
 * @brief  刷新屏幕
 * @param  无
 * @retval 无
 */
void GUI_Refresh(void)
{
    EPD_Display(EPD_Black_Buffer, EPD_Red_Buffer);
}

六、贪吃蛇游戏核心算法

6.1 游戏数据结构设计

为了实现贪吃蛇游戏,我们需要设计一些数据结构来存储游戏状态。

6.1.1 方向枚举

定义蛇的移动方向:

c

运行

复制代码
typedef enum
{
    DIR_UP,
    DIR_DOWN,
    DIR_LEFT,
    DIR_RIGHT
} Direction;
6.1.2 坐标结构体

定义坐标结构体,用于表示蛇的身体和食物的位置:

c

运行

复制代码
typedef struct
{
    uint8_t x;
    uint8_t y;
} Point;
6.1.3 蛇结构体

定义蛇结构体,包含蛇的身体、长度、方向等信息:

c

运行

复制代码
#define MAX_SNAKE_LENGTH 100

typedef struct
{
    Point body[MAX_SNAKE_LENGTH]; // 蛇的身体
    uint8_t length;               // 蛇的长度
    Direction dir;                // 蛇的移动方向
    uint8_t speed;                // 蛇的移动速度
} Snake;
6.1.4 游戏状态结构体

定义游戏状态结构体,包含游戏的各种状态信息:

c

运行

复制代码
typedef struct
{
    Snake snake;       // 蛇
    Point food;        // 食物
    uint32_t score;    // 得分
    uint32_t high_score; // 最高分
    uint8_t game_over; // 游戏结束标志
    uint8_t difficulty; // 游戏难度
} GameState;

6.2 蛇的移动算法

蛇的移动是贪吃蛇游戏的核心。蛇的移动可以分为以下几个步骤:

  1. 根据当前方向,计算蛇头的新位置
  2. 将蛇头的新位置添加到蛇身体的最前面
  3. 如果蛇吃到了食物,则蛇的长度加 1,不删除蛇尾
  4. 如果蛇没有吃到食物,则删除蛇尾

c

运行

复制代码
/**
 * @brief  移动蛇
 * @param  game: 游戏状态
 * @retval 无
 */
void Snake_Move(GameState *game)
{
    Point new_head;
    
    // 计算新蛇头的位置
    new_head = game->snake.body[0];
    switch(game->snake.dir)
    {
        case DIR_UP:
            new_head.y--;
            break;
        case DIR_DOWN:
            new_head.y++;
            break;
        case DIR_LEFT:
            new_head.x--;
            break;
        case DIR_RIGHT:
            new_head.x++;
            break;
    }
    
    // 将新蛇头添加到身体最前面
    for(int i = game->snake.length; i > 0; i--)
    {
        game->snake.body[i] = game->snake.body[i - 1];
    }
    game->snake.body[0] = new_head;
    
    // 检查是否吃到食物
    if(new_head.x == game->food.x && new_head.y == game->food.y)
    {
        game->score += 10;
        game->snake.length++;
        // 生成新的食物
        Food_Generate(game);
    }
    else
    {
        // 删除蛇尾
        // 不需要额外操作,因为上面的循环已经将蛇尾覆盖了
    }
}

6.3 食物生成算法

食物生成算法需要随机生成一个不在蛇身体上的位置。

c

运行

复制代码
/**
 * @brief  生成食物
 * @param  game: 游戏状态
 * @retval 无
 */
void Food_Generate(GameState *game)
{
    uint8_t valid;
    
    do
    {
        valid = 1;
        // 随机生成食物位置
        game->food.x = rand() % (EPD_WIDTH / 8);
        game->food.y = rand() % (EPD_HEIGHT / 8);
        
        // 检查食物是否在蛇的身体上
        for(int i = 0; i < game->snake.length; i++)
        {
            if(game->food.x == game->snake.body[i].x && game->food.y == game->snake.body[i].y)
            {
                valid = 0;
                break;
            }
        }
    } while(!valid);
}

6.4 碰撞检测算法

碰撞检测算法需要检测蛇是否撞到墙壁或自己的身体。

c

运行

复制代码
/**
 * @brief  碰撞检测
 * @param  game: 游戏状态
 * @retval 1表示发生碰撞,0表示没有发生碰撞
 */
uint8_t Collision_Check(GameState *game)
{
    Point head = game->snake.body[0];
    
    // 检测是否撞到墙壁
    if(head.x < 0 || head.x >= (EPD_WIDTH / 8) || head.y < 0 || head.y >= (EPD_HEIGHT / 8))
    {
        return 1;
    }
    
    // 检测是否撞到自己的身体
    for(int i = 1; i < game->snake.length; i++)
    {
        if(head.x == game->snake.body[i].x && head.y == game->snake.body[i].y)
        {
            return 1;
        }
    }
    
    return 0;
}

6.5 游戏逻辑控制

游戏逻辑控制函数负责处理游戏的各种状态,包括游戏初始化、游戏更新、游戏结束等。

c

运行

复制代码
/**
 * @brief  初始化游戏
 * @param  game: 游戏状态
 * @retval 无
 */
void Game_Init(GameState *game)
{
    // 初始化蛇
    game->snake.length = 3;
    game->snake.body[0].x = 10;
    game->snake.body[0].y = 10;
    game->snake.body[1].x = 9;
    game->snake.body[1].y = 10;
    game->snake.body[2].x = 8;
    game->snake.body[2].y = 10;
    game->snake.dir = DIR_RIGHT;
    game->snake.speed = 500; // 移动间隔,单位ms
    
    // 初始化食物
    Food_Generate(game);
    
    // 初始化得分
    game->score = 0;
    
    // 初始化游戏结束标志
    game->game_over = 0;
    
    // 初始化游戏难度
    game->difficulty = 1;
}

/**
 * @brief  更新游戏状态
 * @param  game: 游戏状态
 * @retval 无
 */
void Game_Update(GameState *game)
{
    if(game->game_over)
    {
        return;
    }
    
    // 移动蛇
    Snake_Move(game);
    
    // 碰撞检测
    if(Collision_Check(game))
    {
        game->game_over = 1;
        // 更新最高分
        if(game->score > game->high_score)
        {
            game->high_score = game->score;
            // 保存最高分到Flash
            // ...
        }
    }
}

/**
 * @brief  绘制游戏界面
 * @param  game: 游戏状态
 * @retval 无
 */
void Game_Draw(GameState *game)
{
    // 清空缓冲区
    GUI_Clear(WHITE);
    
    // 绘制边框
    GUI_DrawRectangle(0, 0, EPD_WIDTH - 1, EPD_HEIGHT - 1, BLACK, 0);
    
    // 绘制蛇
    for(int i = 0; i < game->snake.length; i++)
    {
        GUI_DrawRectangle(game->snake.body[i].x * 8, game->snake.body[i].y * 8, 
                         game->snake.body[i].x * 8 + 7, game->snake.body[i].y * 8 + 7, 
                         BLACK, 1);
    }
    
    // 绘制食物
    GUI_DrawRectangle(game->food.x * 8, game->food.y * 8, 
                     game->food.x * 8 + 7, game->food.y * 8 + 7, 
                     RED, 1);
    
    // 绘制得分
    GUI_DrawString(10, 10, "Score:", 16, BLACK);
    GUI_DrawNum(70, 10, game->score, 4, 16, BLACK);
    
    // 绘制最高分
    GUI_DrawString(10, 30, "High:", 16, BLACK);
    GUI_DrawNum(70, 30, game->high_score, 4, 16, BLACK);
    
    // 如果游戏结束,绘制游戏结束提示
    if(game->game_over)
    {
        GUI_DrawChineseString(30, 60, "游戏结束", 24, RED);
        GUI_DrawChineseString(20, 100, "按确认键重新开始", 16, BLACK);
    }
    
    // 刷新屏幕
    GUI_Refresh();
}

七、按键输入处理

7.1 按键硬件设计

本项目使用了 5 个轻触按键,分别用于控制蛇的上下左右移动和确认 / 重新开始。按键采用内部上拉电阻,当按键按下时,GPIO 口会被拉低。

7.2 按键扫描与消抖

由于机械按键在按下和释放时会产生抖动,因此需要进行消抖处理。本项目采用延时消抖的方法,当检测到按键状态变化时,延时 10ms 后再次检测,如果状态相同,则认为按键确实被按下或释放。

c

运行

复制代码
// 按键引脚定义
#define KEY_UP_PIN    GPIO_PIN_0
#define KEY_UP_PORT   GPIOC
#define KEY_DOWN_PIN  GPIO_PIN_1
#define KEY_DOWN_PORT GPIOC
#define KEY_LEFT_PIN  GPIO_PIN_2
#define KEY_LEFT_PORT GPIOC
#define KEY_RIGHT_PIN GPIO_PIN_3
#define KEY_RIGHT_PORT GPIOC
#define KEY_OK_PIN    GPIO_PIN_4
#define KEY_OK_PORT   GPIOC

// 按键状态
typedef enum
{
    KEY_RELEASED,
    KEY_PRESSED
} KeyState;

/**
 * @brief  按键初始化
 * @param  无
 * @retval 无
 */
void Key_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // 使能GPIOC时钟
    __HAL_RCC_GPIOC_CLK_ENABLE();
    
    // 配置按键引脚为输入模式,上拉
    GPIO_InitStruct.Pin = KEY_UP_PIN | KEY_DOWN_PIN | KEY_LEFT_PIN | KEY_RIGHT_PIN | KEY_OK_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
}

/**
 * @brief  扫描按键
 * @param  无
 * @retval 按下的按键,0表示没有按键按下
 */
uint8_t Key_Scan(void)
{
    static KeyState key_state[5] = {KEY_RELEASED};
    uint8_t key_value = 0;
    
    // 检测上键
    if(HAL_GPIO_ReadPin(KEY_UP_PORT, KEY_UP_PIN) == GPIO_PIN_RESET)
    {
        if(key_state[0] == KEY_RELEASED)
        {
            HAL_Delay(10);
            if(HAL_GPIO_ReadPin(KEY_UP_PORT, KEY_UP_PIN) == GPIO_PIN_RESET)
            {
                key_state[0] = KEY_PRESSED;
                key_value = 1;
            }
        }
    }
    else
    {
        key_state[0] = KEY_RELEASED;
    }
    
    // 检测下键
    if(HAL_GPIO_ReadPin(KEY_DOWN_PORT, KEY_DOWN_PIN) == GPIO_PIN_RESET)
    {
        if(key_state[1] == KEY_RELEASED)
        {
            HAL_Delay(10);
            if(HAL_GPIO_ReadPin(KEY_DOWN_PORT, KEY_DOWN_PIN) == GPIO_PIN_RESET)
            {
                key_state[1] = KEY_PRESSED;
                key_value = 2;
            }
        }
    }
    else
    {
        key_state[1] = KEY_RELEASED;
    }
    
    // 检测左键
    if(HAL_GPIO_ReadPin(KEY_LEFT_PORT, KEY_LEFT_PIN) == GPIO_PIN_RESET)
    {
        if(key_state[2] == KEY_RELEASED)
        {
            HAL_Delay(10);
            if(HAL_GPIO_ReadPin(KEY_LEFT_PORT, KEY_LEFT_PIN) == GPIO_PIN_RESET)
            {
                key_state[2] = KEY_PRESSED;
                key_value = 3;
            }
        }
    }
    else
    {
        key_state[2] = KEY_RELEASED;
    }
    
    // 检测右键
    if(HAL_GPIO_ReadPin(KEY_RIGHT_PORT, KEY_RIGHT_PIN) == GPIO_PIN_RESET)
    {
        if(key_state[3] == KEY_RELEASED)
        {
            HAL_Delay(10);
            if(HAL_GPIO_ReadPin(KEY_RIGHT_PORT, KEY_RIGHT_PIN) == GPIO_PIN_RESET)
            {
                key_state[3] = KEY_PRESSED;
                key_value = 4;
            }
        }
    }
    else
    {
        key_state[3] = KEY_RELEASED;
    }
    
    // 检测确认键
    if(HAL_GPIO_ReadPin(KEY_OK_PORT, KEY_OK_PIN) == GPIO_PIN_RESET)
    {
        if(key_state[4] == KEY_RELEASED)
        {
            HAL_Delay(10);
            if(HAL_GPIO_ReadPin(KEY_OK_PORT, KEY_OK_PIN) == GPIO_PIN_RESET)
            {
                key_state[4] = KEY_PRESSED;
                key_value = 5;
            }
        }
    }
    else
    {
        key_state[4] = KEY_RELEASED;
    }
    
    return key_value;
}

7.3 方向控制实现

在游戏主循环中,我们需要不断扫描按键,并根据按键输入来改变蛇的移动方向。需要注意的是,蛇不能直接向相反的方向移动,例如,当蛇正在向右移动时,不能直接向左移动。

c

运行

复制代码
/**
 * @brief  处理按键输入
 * @param  game: 游戏状态
 * @param  key: 按键值
 * @retval 无
 */
void Key_Process(GameState *game, uint8_t key)
{
    switch(key)
    {
        case 1: // 上键
            if(game->snake.dir != DIR_DOWN)
            {
                game->snake.dir = DIR_UP;
            }
            break;
        case 2: // 下键
            if(game->snake.dir != DIR_UP)
            {
                game->snake.dir = DIR_DOWN;
            }
            break;
        case 3: // 左键
            if(game->snake.dir != DIR_RIGHT)
            {
                game->snake.dir = DIR_LEFT;
            }
            break;
        case 4: // 右键
            if(game->snake.dir != DIR_LEFT)
            {
                game->snake.dir = DIR_RIGHT;
            }
            break;
        case 5: // 确认键
            if(game->game_over)
            {
                // 重新开始游戏
                Game_Init(game);
            }
            break;
        default:
            break;
    }
}

八、完整代码实现与注释

8.1 主程序代码 (main.c)

c

运行

复制代码
#include "main.h"
#include "spi.h"
#include "gpio.h"
#include "tim.h"
#include "EPD.h"
#include "EPD_GUI.h"
#include "key.h"
#include "snake.h"

// 游戏状态
GameState game;

// 定时器中断标志
uint8_t timer_flag = 0;

void SystemClock_Config(void);

int main(void)
{
    uint8_t key;
    
    // HAL库初始化
    HAL_Init();
    
    // 系统时钟配置
    SystemClock_Config();
    
    // 外设初始化
    MX_GPIO_Init();
    MX_SPI1_Init();
    MX_TIM2_Init();
    
    // 按键初始化
    Key_Init();
    
    // 电子墨水屏初始化
    EPD_Init();
    EPD_Clear();
    
    // 初始化游戏
    game.high_score = 0;
    Game_Init(&game);
    
    // 显示游戏开始界面
    GUI_Clear(WHITE);
    GUI_DrawChineseString(20, 20, "贪吃蛇游戏", 32, BLACK);
    GUI_DrawChineseString(30, 70, "上下左右控制", 16, BLACK);
    GUI_DrawChineseString(30, 90, "确认键开始", 16, BLACK);
    GUI_Refresh();
    
    // 等待开始键
    while(Key_Scan() != 5)
    {
        HAL_Delay(10);
    }
    
    // 启动定时器
    HAL_TIM_Base_Start_IT(&htim2);
    
    // 游戏主循环
    while(1)
    {
        // 扫描按键
        key = Key_Scan();
        if(key != 0)
        {
            Key_Process(&game, key);
        }
        
        // 定时器中断处理
        if(timer_flag)
        {
            timer_flag = 0;
            
            // 更新游戏状态
            Game_Update(&game);
            
            // 绘制游戏界面
            Game_Draw(&game);
        }
        
        HAL_Delay(10);
    }
}

/**
 * @brief 系统时钟配置
 * @param 无
 * @retval 无
 */
void SystemClock_Config(void)
{
    RCC_OscInitTypeDef RCC_OscInitStruct = {0};
    RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
    RCC_PeriphCLKInitTypeDef PeriphClkInit = {0};

    // 配置内部振荡器
    RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI;
    RCC_OscInitStruct.HSIState = RCC_HSI_ON;
    RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
    RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
    RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSI;
    RCC_OscInitStruct.PLL.PLLM = 1;
    RCC_OscInitStruct.PLL.PLLN = 20;
    RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV7;
    RCC_OscInitStruct.PLL.PLLQ = RCC_PLLQ_DIV2;
    RCC_OscInitStruct.PLL.PLLR = RCC_PLLR_DIV2;
    if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
    {
        Error_Handler();
    }

    // 配置系统时钟
    RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
                              |RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
    RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
    RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
    RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV1;
    RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;

    if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_4) != HAL_OK)
    {
        Error_Handler();
    }

    // 配置外设时钟
    PeriphClkInit.PeriphClockSelection = RCC_PERIPHCLK_SPI1;
    PeriphClkInit.Spi1ClockSelection = RCC_SPI1CLKSOURCE_SYSCLK;
    if (HAL_RCCEx_PeriphCLKConfig(&PeriphClkInit) != HAL_OK)
    {
        Error_Handler();
    }
}

/**
 * @brief 定时器中断回调函数
 * @param htim: 定时器句柄
 * @retval 无
 */
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if(htim->Instance == TIM2)
    {
        timer_flag = 1;
    }
}

/**
 * @brief 错误处理函数
 * @param 无
 * @retval 无
 */
void Error_Handler(void)
{
    __disable_irq();
    while (1)
    {
    }
}

#ifdef USE_FULL_ASSERT
/**
 * @brief 断言失败回调函数
 * @param file: 文件名
 * @param line: 行号
 * @retval 无
 */
void assert_failed(uint8_t *file, uint32_t line)
{
}
#endif

8.2 电子墨水屏驱动代码

见 4.4 节。

8.3 GUI 库代码

见 5.3-5.7 节。

8.4 游戏逻辑代码 (snake.c)

c

运行

复制代码
#include "snake.h"
#include "stdlib.h"

/**
 * @brief  初始化游戏
 * @param  game: 游戏状态
 * @retval 无
 */
void Game_Init(GameState *game)
{
    // 初始化随机数生成器
    srand(HAL_GetTick());
    
    // 初始化蛇
    game->snake.length = 3;
    game->snake.body[0].x = 10;
    game->snake.body[0].y = 10;
    game->snake.body[1].x = 9;
    game->snake.body[1].y = 10;
    game->snake.body[2].x = 8;
    game->snake.body[2].y = 10;
    game->snake.dir = DIR_RIGHT;
    game->snake.speed = 500; // 移动间隔,单位ms
    
    // 初始化食物
    Food_Generate(game);
    
    // 初始化得分
    game->score = 0;
    
    // 初始化游戏结束标志
    game->game_over = 0;
    
    // 初始化游戏难度
    game->difficulty = 1;
}

/**
 * @brief  更新游戏状态
 * @param  game: 游戏状态
 * @retval 无
 */
void Game_Update(GameState *game)
{
    if(game->game_over)
    {
        return;
    }
    
    // 移动蛇
    Snake_Move(game);
    
    // 碰撞检测
    if(Collision_Check(game))
    {
        game->game_over = 1;
        // 更新最高分
        if(game->score > game->high_score)
        {
            game->high_score = game->score;
            // 这里可以添加保存最高分到Flash的代码
        }
    }
}

/**
 * @brief  绘制游戏界面
 * @param  game: 游戏状态
 * @retval 无
 */
void Game_Draw(GameState *game)
{
    // 清空缓冲区
    GUI_Clear(WHITE);
    
    // 绘制边框
    GUI_DrawRectangle(0, 0, EPD_WIDTH - 1, EPD_HEIGHT - 1, BLACK, 0);
    
    // 绘制蛇
    for(int i = 0; i < game->snake.length; i++)
    {
        GUI_DrawRectangle(game->snake.body[i].x * 8, game->snake.body[i].y * 8, 
                         game->snake.body[i].x * 8 + 7, game->snake.body[i].y * 8 + 7, 
                         BLACK, 1);
    }
    
    // 绘制食物
    GUI_DrawRectangle(game->food.x * 8, game->food.y * 8, 
                     game->food.x * 8 + 7, game->food.y * 8 + 7, 
                     RED, 1);
    
    // 绘制得分
    GUI_DrawString(10, 10, "Score:", 16, BLACK);
    GUI_DrawNum(70, 10, game->score, 4, 16, BLACK);
    
    // 绘制最高分
    GUI_DrawString(10, 30, "High:", 16, BLACK);
    GUI_DrawNum(70, 30, game->high_score, 4, 16, BLACK);
    
    // 如果游戏结束,绘制游戏结束提示
    if(game->game_over)
    {
        GUI_DrawChineseString(30, 60, "游戏结束", 24, RED);
        GUI_DrawChineseString(20, 100, "按确认键重新开始", 16, BLACK);
    }
    
    // 刷新屏幕
    GUI_Refresh();
}

/**
 * @brief  移动蛇
 * @param  game: 游戏状态
 * @retval 无
 */
void Snake_Move(GameState *game)
{
    Point new_head;
    
    // 计算新蛇头的位置
    new_head = game->snake.body[0];
    switch(game->snake.dir)
    {
        case DIR_UP:
            new_head.y--;
            break;
        case DIR_DOWN:
            new_head.y++;
            break;
        case DIR_LEFT:
            new_head.x--;
            break;
        case DIR_RIGHT:
            new_head.x++;
            break;
    }
    
    // 将新蛇头添加到身体最前面
    for(int i = game->snake.length; i > 0; i--)
    {
        game->snake.body[i] = game->snake.body[i - 1];
    }
    game->snake.body[0] = new_head;
    
    // 检查是否吃到食物
    if(new_head.x == game->food.x && new_head.y == game->food.y)
    {
        game->score += 10;
        game->snake.length++;
        // 生成新的食物
        Food_Generate(game);
        
        // 根据得分调整难度
        if(game->score % 50 == 0 && game->difficulty < 5)
        {
            game->difficulty++;
            // 调整定时器周期,加快蛇的移动速度
            __HAL_TIM_SET_AUTORELOAD(&htim2, 500 - game->difficulty * 50);
        }
    }
}

/**
 * @brief  生成食物
 * @param  game: 游戏状态
 * @retval 无
 */
void Food_Generate(GameState *game)
{
    uint8_t valid;
    
    do
    {
        valid = 1;
        // 随机生成食物位置,注意要在边框内
        game->food.x = (rand() % ((EPD_WIDTH - 16) / 8)) + 1;
        game->food.y = (rand() % ((EPD_HEIGHT - 16) / 8)) + 1;
        
        // 检查食物是否在蛇的身体上
        for(int i = 0; i < game->snake.length; i++)
        {
            if(game->food.x == game->snake.body[i].x && game->food.y == game->snake.body[i].y)
            {
                valid = 0;
                break;
            }
        }
    } while(!valid);
}

/**
 * @brief  碰撞检测
 * @param  game: 游戏状态
 * @retval 1表示发生碰撞,0表示没有发生碰撞
 */
uint8_t Collision_Check(GameState *game)
{
    Point head = game->snake.body[0];
    
    // 检测是否撞到墙壁
    if(head.x <= 0 || head.x >= (EPD_WIDTH / 8) - 1 || head.y <= 0 || head.y >= (EPD_HEIGHT / 8) - 1)
    {
        return 1;
    }
    
    // 检测是否撞到自己的身体
    for(int i = 1; i < game->snake.length; i++)
    {
        if(head.x == game->snake.body[i].x && head.y == game->snake.body[i].y)
        {
            return 1;
        }
    }
    
    return 0;
}

8.5 按键处理代码 (key.c)

见 7.2-7.3 节。

九、调试与优化

9.1 常见问题与解决方法

在开发过程中,可能会遇到一些常见问题,以下是一些问题的解决方法:

9.1.1 电子墨水屏不显示

可能的原因:

  • 电源问题:检查电源电压是否正常,是否有足够的电流
  • 接线问题:检查 SPI 接口、复位、DC、CS 等引脚是否连接正确
  • 初始化问题:检查初始化代码是否正确,是否发送了正确的初始化命令
  • 时序问题:检查 SPI 时钟频率是否过高,是否符合驱动芯片的要求

解决方法:

  • 用万用表测量电源电压,确保在 3.3V 左右
  • 检查所有接线是否牢固,没有虚焊或短路
  • 仔细核对初始化代码,确保与驱动芯片的要求一致
  • 降低 SPI 时钟频率,例如从 10MHz 降低到 1MHz
9.1.2 显示内容不正确

可能的原因:

  • 数据格式问题:检查图像数据的格式是否正确,是否是高位在前
  • 坐标问题:检查坐标计算是否正确,是否有偏移
  • 缓冲区问题:检查缓冲区的大小是否正确,是否有越界访问

解决方法:

  • 确保图像数据是高位在前,即第一个字节的最高位对应第一个像素
  • 仔细检查坐标计算代码,特别是 X 和 Y 坐标的转换
  • 检查缓冲区的定义和使用,确保没有越界访问
9.1.3 刷新速度慢

电子墨水屏的刷新速度本身就比较慢,通常需要几秒钟的时间。如果刷新速度特别慢,可能的原因:

  • 温度问题:电子墨水屏的刷新速度受温度影响较大,低温下刷新会变慢
  • 电源问题:电源电压不足会导致刷新速度变慢
  • 驱动问题:驱动代码中的延时过长

解决方法:

  • 在适宜的温度下使用电子墨水屏
  • 确保电源电压稳定,有足够的电流
  • 优化驱动代码,减少不必要的延时
9.1.4 按键不灵敏

可能的原因:

  • 硬件问题:按键接触不良,或者上拉电阻不合适
  • 软件问题:消抖时间不合适,或者按键扫描频率太低

解决方法:

  • 检查按键硬件,确保接触良好
  • 调整消抖时间,通常 10-20ms 比较合适
  • 提高按键扫描频率,例如每 10ms 扫描一次

9.2 显示速度优化

由于电子墨水屏的刷新速度较慢,我们可以通过以下方法来优化显示速度:

  1. 局部刷新:只刷新屏幕上变化的部分,而不是整个屏幕。这可以大大减少刷新时间。
  2. 使用快速刷新模式:一些电子墨水屏支持快速刷新模式,可以在较短的时间内完成刷新,但显示效果会有所下降。
  3. 减少刷新次数:尽量减少屏幕的刷新次数,例如在游戏中,只有当蛇移动或吃到食物时才刷新屏幕。

9.3 功耗优化

电子墨水屏本身的功耗很低,但整个系统的功耗还包括微控制器和其他外设的功耗。我们可以通过以下方法来优化系统功耗:

  1. 使用低功耗模式:在系统空闲时,将微控制器进入低功耗模式,例如睡眠模式或停止模式。
  2. 关闭不必要的外设:关闭不使用的外设,例如 ADC、DAC、定时器等。
  3. 降低系统时钟频率:在满足性能要求的前提下,降低系统时钟频率。
  4. 使用电子墨水屏的深度睡眠模式:在不使用屏幕时,将电子墨水屏进入深度睡眠模式。

9.4 代码优化

为了提高代码的执行效率和减少代码占用的空间,我们可以对代码进行以下优化:

  1. 使用宏定义代替函数:对于一些简单的函数,可以使用宏定义来代替,减少函数调用的开销。
  2. 使用静态函数:对于只在本文件中使用的函数,声明为静态函数,可以减少代码的大小。
  3. 优化循环:减少循环中的不必要操作,例如将循环内的不变量提到循环外。
  4. 使用位操作:使用位操作来代替算术运算,可以提高执行效率。
  5. 使用 const 关键字:对于常量数据,使用 const 关键字,可以将数据存储在 Flash 中,节省 RAM 空间。

十、扩展功能与展望

10.1 分数显示

本项目已经实现了基本的分数显示功能,我们可以进一步扩展,例如:

  • 显示当前游戏的时间
  • 显示游戏难度
  • 显示蛇的长度

10.2 游戏难度调节

本项目已经实现了根据得分自动调节游戏难度的功能,我们可以进一步扩展,例如:

  • 允许用户在游戏开始前选择难度
  • 不同难度下,蛇的移动速度不同,食物的生成位置也不同
  • 增加障碍物,增加游戏的难度

10.3 最高分记录

本项目已经实现了最高分记录功能,但目前最高分只保存在 RAM 中,断电后会丢失。我们可以将最高分保存到 Flash 或 EEPROM 中,这样即使断电,最高分也不会丢失。

10.4 声音提示

我们可以添加一个蜂鸣器,在游戏中提供声音提示,例如:

  • 蛇吃到食物时发出 "嘀" 的一声
  • 游戏结束时发出 "嘀嘀嘀" 的声音
  • 按键时发出 "嘀" 的一声

10.5 多玩家模式

我们可以扩展为多玩家模式,例如:

  • 两个玩家分别控制两条蛇,互相竞争
  • 玩家可以通过蓝牙或 WiFi 进行联机游戏
相关推荐
2601_950316061 小时前
索尼PSP中文游戏资源汇总 中文游戏全集+PS1转PSP+金手指+PSP模拟器
游戏
草木深雨纷纷1 小时前
植物大战僵尸精华版下载pvz改版2026最新版分享
游戏·游戏程序
上海云盾-高防顾问1 小时前
游戏陪玩平台如何应用高防保障业务正常运营
游戏
山木嵌入式1 小时前
FreeRTOS任务创建全解析:动态/静态创建+实战案例+参数深度剖析
stm32·freertos
项目題供诗1 小时前
STM32-定时器定时中断&定时器外部时钟(十一)
stm32·单片机·嵌入式硬件
披着假发的程序唐1 小时前
STM32 H743 MPU的配置使用方法
linux·c语言·c++·驱动开发·stm32·单片机·mcu
草木深雨纷纷2 小时前
GTA5mod整合包下载分享(已汉化+自带修改器)2026最新版本
游戏·游戏程序
wild-civil2 小时前
解决Keil 生成的文件在 VSCode 乱码问题(自动识别,不用手动改编码)
ide·vscode·stm32·编辑器
邪修king3 小时前
UE5 进阶篇第一弹:中期架构升级 —— 组件化开发与 Gameplay 框架实战
c++·游戏·架构·ue5