前言
电子墨水屏(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 模块划分
根据功能的不同,本项目的软件划分为以下几个模块:
- 主程序模块:负责系统初始化和主循环
- 电子墨水屏驱动模块:负责与电子墨水屏通信,实现基本的显示功能
- SPI 驱动模块:负责 SPI 接口的初始化和数据传输
- GPIO 驱动模块:负责 GPIO 口的初始化和控制
- 定时器驱动模块:负责定时器的初始化和中断处理
- GUI 图形库模块:提供基本的图形绘制功能,如点、线、矩形、圆、字符等
- 按键处理模块:负责按键的扫描和消抖,处理用户输入
- 游戏逻辑模块:实现贪吃蛇游戏的核心逻辑,包括蛇的移动、食物生成、碰撞检测等
- 数据存储模块:负责最高分等数据的存储和读取
3.3 主程序流程
主程序的流程如下:
-
系统初始化
- 初始化系统时钟
- 初始化 GPIO 口
- 初始化 SPI 接口
- 初始化定时器
- 初始化电子墨水屏
- 初始化游戏数据
-
显示游戏开始界面
- 绘制游戏标题
- 绘制操作说明
- 绘制开始提示
-
等待用户按下开始键
-
游戏主循环
- 扫描按键,处理用户输入
- 更新游戏状态
- 绘制游戏界面
- 刷新电子墨水屏
- 检测游戏是否结束
-
游戏结束
- 显示游戏结束界面
- 显示最终得分
- 显示最高分
- 等待用户按下重新开始键
-
返回步骤 4,重新开始游戏
四、电子墨水屏驱动原理与实现
4.1 电子墨水屏显示原理详解
如前所述,电子墨水屏利用电泳现象来实现显示。每个像素点包含一个微小的胶囊,胶囊内装有带正电的白色粒子、带负电的黑色粒子和带负电的红色粒子。
当在像素点的上下电极施加不同极性和大小的电压时,带电粒子会在电场的作用下向不同的方向移动。例如:
- 当在上电极施加负电压,下电极施加正电压时,带正电的白色粒子会向上移动到上电极附近,此时该像素点显示白色
- 当在上电极施加正电压,下电极施加负电压时,带负电的黑色和红色粒子会向上移动到上电极附近。此时,通过控制电压的大小和持续时间,可以控制黑色和红色粒子的比例,从而显示黑色或红色
电子墨水屏的刷新过程通常包括以下几个阶段:
- 复位阶段:对所有像素点施加相同的电压,将所有粒子复位到初始状态
- 擦除阶段:将整个屏幕刷新为白色
- 写入阶段:根据要显示的图像数据,对每个像素点施加相应的电压,将粒子移动到正确的位置
- 停止阶段:移除电压,粒子保持在当前位置,显示完成
由于电子墨水屏的刷新需要一定的时间(通常为几秒钟),因此在刷新过程中不能向驱动芯片发送任何命令,否则会导致显示异常。
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 通信的时序如下:
- 当 CS 信号为低电平时,芯片被选中,可以进行通信
- DC 信号为低电平时,表示传输的是命令;DC 信号为高电平时,表示传输的是数据
- 在 SCLK 的上升沿,数据从 MOSI 引脚移位到芯片内部的移位寄存器
- 数据传输完成后,将 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,不删除蛇尾
- 如果蛇没有吃到食物,则删除蛇尾
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 显示速度优化
由于电子墨水屏的刷新速度较慢,我们可以通过以下方法来优化显示速度:
- 局部刷新:只刷新屏幕上变化的部分,而不是整个屏幕。这可以大大减少刷新时间。
- 使用快速刷新模式:一些电子墨水屏支持快速刷新模式,可以在较短的时间内完成刷新,但显示效果会有所下降。
- 减少刷新次数:尽量减少屏幕的刷新次数,例如在游戏中,只有当蛇移动或吃到食物时才刷新屏幕。
9.3 功耗优化
电子墨水屏本身的功耗很低,但整个系统的功耗还包括微控制器和其他外设的功耗。我们可以通过以下方法来优化系统功耗:
- 使用低功耗模式:在系统空闲时,将微控制器进入低功耗模式,例如睡眠模式或停止模式。
- 关闭不必要的外设:关闭不使用的外设,例如 ADC、DAC、定时器等。
- 降低系统时钟频率:在满足性能要求的前提下,降低系统时钟频率。
- 使用电子墨水屏的深度睡眠模式:在不使用屏幕时,将电子墨水屏进入深度睡眠模式。
9.4 代码优化
为了提高代码的执行效率和减少代码占用的空间,我们可以对代码进行以下优化:
- 使用宏定义代替函数:对于一些简单的函数,可以使用宏定义来代替,减少函数调用的开销。
- 使用静态函数:对于只在本文件中使用的函数,声明为静态函数,可以减少代码的大小。
- 优化循环:减少循环中的不必要操作,例如将循环内的不变量提到循环外。
- 使用位操作:使用位操作来代替算术运算,可以提高执行效率。
- 使用 const 关键字:对于常量数据,使用 const 关键字,可以将数据存储在 Flash 中,节省 RAM 空间。
十、扩展功能与展望
10.1 分数显示
本项目已经实现了基本的分数显示功能,我们可以进一步扩展,例如:
- 显示当前游戏的时间
- 显示游戏难度
- 显示蛇的长度
10.2 游戏难度调节
本项目已经实现了根据得分自动调节游戏难度的功能,我们可以进一步扩展,例如:
- 允许用户在游戏开始前选择难度
- 不同难度下,蛇的移动速度不同,食物的生成位置也不同
- 增加障碍物,增加游戏的难度
10.3 最高分记录
本项目已经实现了最高分记录功能,但目前最高分只保存在 RAM 中,断电后会丢失。我们可以将最高分保存到 Flash 或 EEPROM 中,这样即使断电,最高分也不会丢失。
10.4 声音提示
我们可以添加一个蜂鸣器,在游戏中提供声音提示,例如:
- 蛇吃到食物时发出 "嘀" 的一声
- 游戏结束时发出 "嘀嘀嘀" 的声音
- 按键时发出 "嘀" 的一声
10.5 多玩家模式
我们可以扩展为多玩家模式,例如:
- 两个玩家分别控制两条蛇,互相竞争
- 玩家可以通过蓝牙或 WiFi 进行联机游戏