
1. 项目概述当经典文字游戏遇上电子墨水屏“Hangman”猜词游戏大概是很多人接触编程时写的第一个文字游戏。它的规则简单到极致系统随机选择一个单词玩家每次猜一个字母猜错一次绞刑架上的小人就多画一笔直到单词被完全猜出或小人被“吊死”。这个项目的有趣之处在于我把它从传统的命令行或图形界面移植到了一块小小的电子墨水屏E-ink上。这不仅仅是换了个显示设备而是从交互逻辑、功耗管理到用户体验的一次彻底重构。为什么是E-ink它那种类似纸张、不发光、超低功耗的显示特性与“Hangman”这种节奏舒缓、需要凝视思考的游戏简直是天作之合。想象一下一个可以像便签纸一样贴在桌角几周甚至几个月都不需要充电的游戏设备每次猜对或猜错一个字母屏幕上的笔画才“慢悠悠”地刷新一次这种独特的“延迟感”反而增添了一种从容和仪式感。这不仅仅是技术上的“炫技”更是对一种特定场景下用户体验的探索——比如作为孩子的单词学习工具或者放在咖啡馆桌面上的一个解压小玩具。这个项目的核心就是打通从游戏逻辑到E-ink硬件驱动之间的所有环节。你需要处理的不再是print()语句而是具体的像素操作、局部刷新策略以及极致的电源管理。下面我将从硬件选型、软件架构、核心驱动实现到交互优化完整拆解这个将经典游戏赋予全新生命的过程。2. 硬件选型与系统架构设计2.1 E-ink显示屏的选择考量市面上的E-ink屏模块种类繁多选择哪一款直接决定了项目的复杂度和最终体验。对于“Hangman”游戏我们需要重点评估以下几个参数尺寸与分辨率游戏需要显示一个绞刑架小人约10x15像素的简笔画、一个单词的空白格每个字母位、已猜错的字母列表以及简单的提示信息。一块2.9英寸或4.2英寸分辨率在296x128或400x300左右的屏幕就完全足够。太大反而显得笨重失去了“小玩意”的精致感。色彩单色黑白屏幕是最经济、刷新也最快的选择完全契合游戏需求。三色黑白红屏幕可以增加一点表现力比如用红色标出猜错的字母但驱动会稍复杂刷新速度也可能受影响。对于初版强烈建议从经典的黑白屏开始。驱动IC与接口常见的驱动IC如SSD1680、IL3895等它们通常通过SPI接口与主控芯片通信。选择一款有成熟开源驱动库如GxEPD2、Adafruit_EPD支持的屏幕能节省大量底层开发时间。刷新模式这是E-ink项目的灵魂。全局刷新Full Update会清空整个屏幕再绘制有强烈的闪烁感但显示最干净。局部刷新Partial Update只刷新变化的部分速度快、无闪烁但长期使用可能导致屏幕残留“鬼影”。我们的游戏画面变化不频繁每次猜字母才变一次且区域固定小人、单词、字母列表非常适合采用局部刷新来获得流畅体验。基于以上我最终选择了一款4.2英寸、400x300分辨率、使用SSD1680驱动IC的黑白E-ink屏。它价格适中Arduino和CircuitPython社区都有很好的支持作为起点非常合适。注意购买时务必确认屏幕是否支持“局部刷新”Partial Refresh。有些廉价模块为了省成本可能阉割了此功能或者需要特定的初始化序列才能开启这会在后期带来很多麻烦。2.2 主控微控制器的抉择主控芯片是项目的大脑负责运行游戏逻辑、驱动屏幕、读取输入。可选方案很多Arduino Uno/Nano入门最简单生态丰富。但内存2KB RAM和闪存32KB非常紧张处理稍大点的字库或复杂刷新就可能捉襟见肘。ESP32强大的双核处理器充足的RAM和闪存内置Wi-Fi/蓝牙虽然本项目用不上。其主频高能更快地处理屏幕缓冲区和刷新指令是性能最充裕的选择。但功耗相对较高需要仔细管理睡眠模式。Raspberry Pi Pico (RP2040)性价比极高双核ARM Cortex-M0264KB RAM。性能介于Arduino和ESP32之间功耗控制优秀且GPIO功能灵活。社区支持正在飞速增长。考虑到我们需要维护一个帧缓冲区Framebuffer来管理屏幕图像并可能存储一个英文单词列表对内存有一定要求。同时我们希望设备能依靠电池长期待机。因此Raspberry Pi Pico成了一个平衡点性能足够功耗可控价格低廉。我选择使用C/C配合Arduino框架进行开发以利用现有的E-ink库。2.3 输入方式的巧妙设计游戏需要玩家输入26个字母。在E-ink设备上我们不能依赖键盘。这里有几种交互设计思路物理按键最直接。可以设计一个“A-Z”的滚轮选择器一个编码器旋钮加一个“确认”键。或者粗暴地放26个按键不现实。优点是反馈明确缺点是硬件复杂。触摸屏部分E-ink屏支持电容或电阻触摸。可以在屏幕上绘制一个虚拟键盘。交互最直观但支持触摸的E-ink屏更贵且驱动更复杂。手势或加速度计通过晃动设备选择字母如晃一下切到下一个字母趣味性强但精度差容易误操作。外接蓝牙键盘通过ESP32的蓝牙连接手机或小键盘这背离了“一体式小设备”的初衷。为了保持设备的简洁和独立性我选择了方案一的变种使用一个旋转编码器Rotary Encoder和一个按键。旋转编码器用来在A-Z之间滚动选择字母中间的按下动作作为“确认猜测”侧面的独立按键作为“开始新游戏”或“重置”。这种方案硬件简单成本低且操作有明确的“咔哒”手感与E-ink的“慢节奏”很搭。系统架构图文字描述 整个系统的数据流是这样的主循环中程序不断读取旋转编码器的脉冲和按键状态。当玩家旋转编码器时程序更新一个当前选中的字母变量并立即在屏幕的特定区域如底部刷新显示这个被选中的字母。当玩家按下编码器确认猜测时游戏逻辑核心被触发判断字母是否在目标单词中。根据判断结果更新“绞刑架”的绘制状态、单词的显示状态以及错误字母列表然后计算发生变化的屏幕区域调用E-ink驱动的局部刷新函数只更新这些区域。整个过程中主控大部分时间处于空闲或浅睡眠状态极其省电。3. 核心驱动与图形渲染实现3.1 E-ink驱动库的集成与初始化我选择了经过广泛测试的GxEPD2库。它的优势在于对多种驱动IC封装良好且直接支持帧缓冲区Buffer操作和局部刷新。首先在Arduino IDE中安装库。然后根据屏幕型号定义显示对象。对于我的4.2英寸SSD1680屏幕初始化代码如下#include GxEPD2_BW.h #include Fonts/FreeMonoBold9pt7b.h // 包含一个字体 // 定义屏幕引脚连接根据你的实际接线修改 #define EPD_CS 5 #define EPD_DC 6 #define EPD_RST 7 #define EPD_BUSY 8 // 创建显示对象参数驱动IC型号 CS, DC, RST, BUSY GxEPD2_BWGxEPD2_420, GxEPD2_420::HEIGHT display(GxEPD2_420(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY)); void setup() { Serial.begin(115200); display.init(115200); // 初始化屏幕可传入串口调试波特率 display.setRotation(1); // 根据需要设置旋转0-3 display.setFont(FreeMonoBold9pt7b); // 设置字体 display.setTextColor(GxEPD_BLACK); // 设置文本颜色 display.setFullWindow(); // 设置为全窗口模式为首次全刷准备 display.fillScreen(GxEPD_WHITE); // 清屏为白色 display.display(false); // 执行全刷false表示不等待 display.hibernate(); // 让屏幕进入深度睡眠省电 }关键点在于display.hibernate()。E-ink屏只在刷新时需要电流刷新完成后应立即让其休眠这是超低功耗的关键。3.2 帧缓冲区的管理与局部刷新策略GxEPD2库允许我们直接在一个内存缓冲区Buffer里作画然后一次性发送到屏幕。我们需要管理两个逻辑层一个是代表屏幕最终状态的“背景层”另一个是用于临时绘制的“活动层”。对于“Hangman”游戏屏幕内容可以分为几个静态区域如标题“Hangman”和动态区域小人、单词、错误字母。最优策略是游戏开始时用全局刷新绘制所有静态元素。之后任何动态更新都只重绘对应的动态区域到缓冲区然后调用局部刷新。// 假设我们定义了动态区域的坐标 #define HANGMAN_X 10 #define HANGMAN_Y 30 #define HANGMAN_WIDTH 100 #define HANGMAN_HEIGHT 60 #define WORD_X 10 #define WORD_Y 110 #define WRONG_LETTERS_X 150 #define WRONG_LETTERS_Y 30 void updateHangman(int wrongCount) { // 1. 设置局部刷新区域 display.setPartialWindow(HANGMAN_X, HANGMAN_Y, HANGMAN_WIDTH, HANGMAN_HEIGHT); // 2. 在缓冲区中先把这个区域用白色填充擦除旧图 display.fillRect(HANGMAN_X, HANGMAN_Y, HANGMAN_WIDTH, HANGMAN_HEIGHT, GxEPD_WHITE); // 3. 根据wrongCount绘制小人相应的部分头、身体、四肢等 drawHangmanParts(wrongCount); // 自定义的绘制函数 // 4. 执行局部刷新 display.displayPartial(HANGMAN_X, HANGMAN_Y, HANGMAN_WIDTH, HANGMAN_HEIGHT); // 5. 可选刷新后再次休眠屏幕 // display.hibernate(); }实操心得局部刷新的坐标和尺寸必须精确。如果刷新区域设置得比实际绘图区域大会导致不必要的屏幕区域刷新增加功耗和时间如果设置小了会导致部分更新内容显示不出来。最好在代码里用常量定义好每个动态区域的范围。3.3 游戏状态与图形元素的绘制“绞刑架小人”的绘制是图形部分的核心。我们可以用一个函数来根据错误次数wrongCount0-6绘制不同的状态。void drawHangmanParts(int stage) { // 绘制固定的绞刑架 display.drawLine(10, 50, 50, 50, GxEPD_BLACK); // 底座 display.drawLine(30, 50, 30, 10, GxEPD_BLACK); // 柱子 display.drawLine(30, 10, 60, 10, GxEPD_BLACK); // 横梁 display.drawLine(60, 10, 60, 20, GxEPD_BLACK); // 绳子 // 根据阶段绘制小人 if(stage 1) display.drawCircle(60, 25, 5, GxEPD_BLACK); // 头 if(stage 2) display.drawLine(60, 30, 60, 45, GxEPD_BLACK); // 身体 if(stage 3) display.drawLine(60, 35, 50, 40, GxEPD_BLACK); // 左手 if(stage 4) display.drawLine(60, 35, 70, 40, GxEPD_BLACK); // 右手 if(stage 5) display.drawLine(60, 45, 55, 55, GxEPD_BLACK); // 左脚 if(stage 6) display.drawLine(60, 45, 65, 55, GxEPD_BLACK); // 右脚 }单词的显示则用下划线“_”表示未猜字母猜中后替换为字母。错误字母列表则简单地在指定区域累加显示。4. 游戏逻辑与输入处理集成4.1 旋转编码器的驱动与去抖旋转编码器看似简单但软件处理不当会非常“跳票”。我们需要处理抖动和方向判断。这里使用Encoder库来简化操作。#include Encoder.h Encoder myEncoder(2, 3); // 引脚2,3连接编码器DT和CLK int lastEncoderPos 0; char currentLetter A; // 当前选中的字母 void checkEncoder() { long newPos myEncoder.read() / 4; // 每4个计数对应一个“咔哒”感 if (newPos ! lastEncoderPos) { int delta newPos - lastEncoderPos; lastEncoderPos newPos; // 根据delta正负更新currentLetter并限制在A到Z之间 currentLetter delta; if (currentLetter A) currentLetter Z; if (currentLetter Z) currentLetter A; // 立即在屏幕上刷新当前选中的字母局部刷新 updateSelectedLetterArea(currentLetter); } }按键去抖则使用简单的状态机和非阻塞延时判断避免使用delay()。4.2 游戏核心状态机游戏逻辑可以用一个清晰的状态机State Machine来描述这使代码结构更清晰。enum GameState { STATE_IDLE, // 空闲显示欢迎语 STATE_PLAYING, // 游戏中 STATE_WIN, // 获胜 STATE_LOSE // 失败 }; GameState currentState STATE_IDLE; String targetWord ARDUINO; String guessedWord _______; // 与targetWord等长 String wrongLetters ; int wrongCount 0; const int MAX_WRONG 6; void gameLoop() { switch(currentState) { case STATE_IDLE: // 显示“Press to start” break; case STATE_PLAYING: checkEncoder(); checkGuessButton(); // 检查确认键 break; case STATE_WIN: // 显示“You Win!”闪烁单词 break; case STATE_LOSE: // 显示“Game Over”显示正确答案 break; } } void processGuess(char letter) { if (currentState ! STATE_PLAYING) return; bool correct false; // 检查字母是否在目标单词中 for (int i 0; i targetWord.length(); i) { if (targetWord[i] letter guessedWord[i] _) { guessedWord[i] letter; correct true; } } if (correct) { updateWordDisplay(); // 局部刷新单词区域 // 检查是否获胜 if (guessedWord.indexOf(_) -1) { currentState STATE_WIN; showWinAnimation(); } } else { // 错误处理 if (wrongLetters.indexOf(letter) -1) { // 避免重复记录 wrongLetters letter; wrongCount; updateWrongLettersDisplay(); // 局部刷新错误字母区 updateHangman(wrongCount); // 局部刷新小人 // 检查是否失败 if (wrongCount MAX_WRONG) { currentState STATE_LOSE; showLoseMessage(); } } } }4.3 单词库的设计与存储单词库可以硬编码在程序里但对于Pico有2MB Flash或ESP32我们可以利用其文件系统LittleFS/SPIFFS来存储一个单词列表文件如words.txt每行一个单词。这样无需重新编译程序就能更新词库。#include LittleFS.h String getRandomWord() { File file LittleFS.open(/words.txt, r); if (!file) { return DEFAULT; // 后备单词 } // 简单随机先数行数再随机跳到一行 int lineCount 0; while (file.available()) { if (file.read() \n) lineCount; } file.seek(0); int targetLine random(0, lineCount); String word; for (int i 0; i targetLine; i) { word file.readStringUntil(\n); } word.trim(); // 去除换行符和空格 file.close(); return word; }注意事项从文件系统读取单词在启动时或新游戏时发生频率很低对功耗影响微乎其微。但要注意单词长度应适合屏幕显示避免过长的单词。5. 低功耗优化与电源管理E-ink项目的魅力在于其极致的续航。除了让屏幕休眠主控芯片的功耗管理同样重要。主控睡眠在STATE_IDLE等待开始状态下如果没有操作系统可以进入深度睡眠Deep Sleep。对于Pico这需要连接RUN引脚到特定唤醒源如按键。更简单的方案是使用“空闲睡眠”Light Sleep在循环中通过delay()或esp_sleep函数让CPU暂停由外部中断编码器旋转或按键按下唤醒。这能大幅降低待机电流。外设断电不使用时可以关闭编码器引脚的上拉电阻如果软件启用的话或者将其设置为输入模式以减少漏电。刷新频率确保只有在必要时才刷新屏幕并且优先使用局部刷新。全局刷新非常耗电应仅在必要时如新游戏开始、严重残影时使用。电源设计如果使用电池供电建议选择容量较大的锂聚合物电池如500mAh以上并搭配一个高效的3.3V稳压模块。计算一下功耗屏幕刷新瞬间电流可能达几十mA但持续时间极短几百毫秒。主控在深度睡眠下电流可降至几十微安。假设每天玩10分钟大部分时间在睡眠那么一个500mAh的电池续航数月是完全可行的。实测中我将系统配置为5分钟无操作后进入空闲睡眠电流约2mA按下任意键唤醒。在持续游戏频繁刷新状态下平均电流约15mA。这个功耗水平非常令人满意。6. 常见问题与调试技巧实录在开发过程中我遇到了不少典型问题这里记录下来供大家参考。问题现象可能原因排查与解决思路屏幕全白或全黑无任何显示1. 电源电压不足E-ink需要3.3V稳定供电。2. SPI引脚接错CS, DC, MOSI, SCK。3. 复位时序不对或RST引脚未正确连接/控制。1. 用万用表测量屏幕VCC引脚电压确保在3.3V左右且稳定。2. 对照数据手册逐一检查SPI连线。CS引脚通常需要上拉。3. 在初始化代码中手动控制RST引脚先拉低200ms再拉高然后延时200ms再执行display.init()。局部刷新有严重“鬼影”残影1. 局部刷新次数过多未进行周期性全局刷新。2. 屏幕本身局部刷新性能不佳廉价屏通病。3. 刷新前未正确清除缓冲区旧内容。1. 每进行20-30次局部刷新后强制进行一次全局刷新display.display(true)。2. 尝试调整局部刷新的波形参数display.setPartialWindow相关参数不同屏幕最佳值不同需查阅驱动IC手册或实验。3. 确保在setPartialWindow后立即用fillRect将刷新区域填充为白色。旋转编码器读数乱跳不准确1. 硬件抖动。2. 中断冲突或读取太频繁。3. 编码器A/B相序接反。1. 在编码器引脚增加1040.1uF电容到地进行硬件滤波。2. 使用带内部去抖的编码器库如Encoder并确保不在中断服务程序ISR中做复杂操作。3. 交换编码器的DT和CLK线试试。程序运行一段时间后死机或重启1. 内存泄漏频繁的String操作导致堆碎片化。2. 看门狗Watchdog超时。3. 电源不稳定。1. 避免在循环中动态创建String对象。对固定的字符串使用const char*或F()宏。使用String.reserve()预分配空间。2. 在长时间操作如文件读取、复杂刷新中适时调用yield()或delay(0)喂狗。3. 检查电池电量或给系统电源增加一个大电容如100uF缓冲。单词显示乱码或字体不对1. 未正确设置字体或字体不支持显示字符。2. 显示坐标计算错误文本画出界。3. 帧缓冲区颜色设置错误。1. 确认display.setFont()被调用且包含的字体文件确实有当前字符的字形。对于纯字母数字FreeMonoBold9pt7b足够。2. 使用display.getTextBounds()函数预先计算文本占据的边界框再确定绘制起点。3. 绘制文本前用display.setTextColor(GxEPD_BLACK)设置颜色。独家避坑技巧调试信息输出在代码关键节点如进入局部刷新前、猜字母后通过串口打印状态信息如Serial.printf(Pos: %d, Letter: %c\n, pos, letter)。即使最终产品不需要串口在开发阶段这是最强大的调试工具。分阶段测试不要试图一次性写完所有功能。先让屏幕能显示静态文字测试驱动再让编码器能控制一个光标移动测试输入最后才集成游戏逻辑。每步稳扎稳打。功耗测量准备一个万用表串联在电池和主板之间测量不同状态全速运行、空闲、深度睡眠下的电流。这是优化续航最直接的依据。你会发现让屏幕休眠和主控睡眠带来的功耗下降是数量级的。这个项目从构思到实现最深的体会是硬件项目是软件逻辑与物理世界约束的舞蹈。E-ink的慢刷新不是缺点而是塑造产品个性的特点。当你看到那个由简单线条构成的小人随着每一次错误的猜测在如同纸张的屏幕上缓缓浮现时那种独特的交互感和成就感是任何高速液晶屏都无法给予的。它提醒我们技术不只是关于更快更强更是关于在特定的约束下创造出恰到好处的体验。