1. 项目概述:从零到一,在行空板上构建你的第一个图形化游戏
如果你已经跟着前几课,点亮了LED,玩转了传感器,甚至让行空板上的屏幕显示出了文字和图案,那么恭喜你,你已经具备了挑战一个里程碑式项目的基础能力。第六课,我们将不再满足于简单的输入输出,而是要整合你学到的所有知识,亲手打造一个经典的、可交互的图形化小游戏——贪吃蛇。这不仅仅是写几行代码,而是一次完整的项目实战,涵盖了事件驱动编程、图形界面绘制、游戏状态管理、用户交互处理等多个核心编程概念。对于初学者而言,完成一个能跑起来的贪吃蛇,其成就感远超点亮一百个LED灯。它标志着你的编程思维从“顺序执行”迈向了“事件响应与状态维护”的新阶段。本教程将手把手带你,使用行空板内置的Python环境及其强大的pinpong库和unihiker库,从游戏规则设计开始,到每一行代码的编写与调试,最终在行空板那块漂亮的屏幕上,看到由你控制的像素小蛇灵活游走、吞食食物、不断成长的完整过程。无论你是编程新手,还是有一定基础想了解嵌入式图形开发的朋友,这篇教程都将提供一条清晰、可复现的路径。
2. 游戏核心设计与逻辑拆解
在动手写代码之前,我们必须像建筑师绘制蓝图一样,先把贪吃蛇这个游戏的“骨架”和“运转规则”想清楚。一个游戏程序,本质是一个持续运行的状态机,我们需要明确它的核心组成部分和它们之间的互动关系。
2.1 游戏基本规则与状态定义
贪吃蛇的规则看似简单,但用程序语言精确描述却需要一番思考。首先,我们定义游戏的核心元素:
- 蛇(Snake):由多个连续方块(或圆点)组成的序列。它有一个“头部”和“身体”。初始长度通常为1或3个方块。
- 食物(Food):一个随机出现在游戏区域内的单个方块。蛇的目标就是吃掉它。
- 游戏区域(Board):一个固定大小的矩形区域,所有游戏活动都发生在此区域内。蛇不能超出这个区域。
接下来是游戏的状态与规则:
- 移动规则:蛇持续朝当前方向(上、下、左、右)移动。移动的实现方式是:在蛇的头部前方增加一个新的方块(代表新的头部),同时移除蛇尾部的最后一个方块(除非刚吃到食物)。这样就在视觉上形成了“向前爬行”的效果。
- 进食规则:当蛇的头部与食物的坐标重合时,判定为“吃到食物”。此时,食物被“消化”,蛇的长度增加(即不移除尾部方块),并在游戏区域内随机一个新位置生成新的食物。同时,玩家得分增加。
- 死亡规则:当蛇的头部撞到游戏区域的边界,或者撞到自己的身体时,游戏结束。
注意:在编程实现中,“撞到自己”的判断需要小心。通常我们检查蛇头是否与身体部分的任何一个方块坐标重合。但要注意,在蛇移动的瞬间,旧头部还未被移除,新头部即将覆盖旧头部的位置,这个逻辑需要处理好,避免误判。
2.2 程序架构与模块划分
基于以上规则,我们可以将程序划分为几个功能模块,这会让我们的代码结构清晰,易于编写和调试。
初始化模块:负责游戏开始时的准备工作。包括:
- 创建游戏窗口(使用unihiker库)。
- 初始化蛇的初始位置、长度和方向。
- 随机生成第一个食物的位置。
- 初始化游戏分数、速度等变量。
- 绘制初始的游戏画面(蛇、食物、分数等)。
游戏主循环模块:这是游戏的心脏,一个永不停止(直到游戏结束)的循环。在每一次循环中,它按顺序做以下几件事:
- 处理输入:检测用户按键(行空板上的物理按键或屏幕虚拟按键),并据此更新蛇的移动方向。这里要注意一个关键点:通常禁止蛇直接反向移动(例如正在向右移动时不能立即按左键),因为这会导致蛇“自杀”。
- 更新游戏状态:根据当前方向,计算蛇头的新位置。然后依次判断:
- 新位置是否与食物重合?是则执行“进食逻辑”。
- 新位置是否撞墙或撞到自己?是则触发“游戏结束逻辑”。
- 否则,执行正常的“移动逻辑”。
- 重新绘制画面:根据更新后的游戏状态(蛇的坐标列表、食物坐标、分数),在屏幕上擦除旧画面,绘制新画面。
- 控制游戏速度:通过循环中的延时(如
time.sleep())来控制蛇的移动速度,从而控制游戏难度。
绘制模块:封装所有在屏幕上画图的函数。例如:
draw_snake(snake_list): 根据蛇的坐标列表,绘制一系列方块。draw_food(food_x, food_y): 在指定坐标绘制食物。draw_score(score): 在屏幕固定位置显示当前分数。draw_game_over(): 游戏结束时显示“Game Over”字样和最终分数。
输入处理模块:专门负责监听行空板的按键事件。行空板提供了GPIO按键和触摸屏,我们可以用
pinpong库来读取物理按键,用unihiker库的GUI组件或事件绑定来处理触摸输入。
将代码按模块划分后,你的main.py文件结构会非常清晰,后期想增加暂停功能、难度选择等功能时,也能轻松地在对应模块进行扩展。
3. 开发环境搭建与核心库详解
工欲善其事,必先利其器。在行空板上开发Python游戏,我们主要依赖两个强大的库:unihiker和pinpong。理解它们的分工是高效开发的关键。
3.1 Unihiker库:图形界面与事件驱动的核心
unihiker库是行空板官方为图形化、交互式应用开发量身定制的。它基于tkinter,但进行了大量简化和硬件适配,让你能用更简单的代码控制屏幕显示和触摸事件。
- 核心对象:GUI()这是所有图形操作的起点。通过
from unihiker import GUI导入,并实例化gui = GUI(),你就获得了一个画布对象,可以在上面添加各种组件。from unihiker import GUI import time gui = GUI() # 初始化GUI对象,这会自动打开一个显示窗口 - 绘制图形:
gui对象提供了draw_系列方法,如draw_line(),draw_rectangle(),draw_oval(),draw_text()等。这些方法会立即在屏幕上绘制图形,并返回一个“对象ID”,方便后续修改或删除。# 画一个红色的正方形作为蛇的一节 snake_segment = gui.draw_rectangle(x=50, y=50, x2=70, y2=70, width=2, color="red") # 画一个绿色的圆作为食物 food = gui.draw_oval(x=100, y=100, x2=120, y2=120, width=0, fill="green") # width=0表示无边框,fill表示填充色 # 显示分数 score_text = gui.draw_text(x=10, y=10, text="Score: 0", font_size=20, color="white") - 更新与删除:要移动或改变一个图形,你需要先删除旧的,再绘制新的。或者,更高效的做法是使用
gui.clear()清屏后重绘所有元素(这在游戏每一帧中很常见)。gui.clear() # 清除屏幕上所有由gui绘制的元素 # 然后重新绘制当前帧的所有游戏元素 - 事件绑定:这是实现交互的关键。
unihiker允许你为按键或触摸事件绑定处理函数。def on_key_event(event): if event == "up": print("上键被按下") # 改变蛇的方向为上 # 绑定物理按键事件(需配合pinpong库,见下文) # 或者绑定虚拟按键(在屏幕上画一个按钮并绑定触摸事件) btn = gui.draw_text(x=200, y=50, text="Start", font_size=30) def on_touch(event): if event == "touch": print("按钮被触摸") start_game() gui.bind(btn, on_touch) # 将触摸事件绑定到btn这个文本对象上
3.2 Pinpong库:读取物理按键状态
行空板上有多个可编程的物理按键(通常标记为A、B)。pinpong库让你能像读取传感器一样读取这些按键是按下还是松开。
- 初始化与读取:
from pinpong.board import Board, Pin Board().begin() # 初始化板子 # 假设按键A连接在引脚Pin.P21(具体引脚号需查行空板文档) button_a = Pin(Pin.P21, Pin.IN) # 在主循环中读取按键状态 while True: if button_a.read_digital() == 0: # 通常按下时为低电平0 print("按键A被按下") # 处理按键逻辑,例如改变蛇的方向为左 time.sleep(0.1) # 防抖延时 - 按键防抖:机械按键在按下和松开的瞬间会产生不稳定的电平信号,称为“抖动”。直接读取可能会导致一次按下被误判为多次。简单的软件防抖方法是在检测到按键按下后,加入一个短暂的延时(如20-50毫秒),再读取一次状态确认。
if button_a.read_digital() == 0: time.sleep(0.05) # 延时50毫秒防抖 if button_a.read_digital() == 0: # 确认仍然是按下状态 # 执行真正的按键处理逻辑
3.3 项目文件结构与初始化代码
建议在你的行空板项目目录中这样组织文件:
/project_snake/ ├── main.py # 主程序入口,包含游戏主循环 ├── game.py # 游戏逻辑类(如Snake, Food, Game) ├── draw.py # 绘图函数模块 └── config.py # 配置文件(如屏幕尺寸、格子大小、颜色、速度等)在main.py的开头,我们会进行统一的初始化:
# main.py import time from unihiker import GUI from pinpong.board import Board, Pin import game # 导入自定义的游戏逻辑模块 import draw # 导入自定义的绘图模块 import config # 导入配置 # 初始化 Board().begin() gui = GUI() # 初始化游戏状态 snake_game = game.GameState(board_width=config.WIDTH, board_height=config.HEIGHT) drawer = draw.Drawer(gui, config) # 绑定按键(示例) button_up = Pin(Pin.P22, Pin.IN) # ... 绑定其他按键 # 游戏主循环将在后面实现这种模块化的设计,使得代码易于管理和协作。你可以让一个人专门负责game.py里的算法,另一个人负责draw.py里的画面效果。
4. 贪吃蛇游戏核心逻辑的代码实现
现在,我们进入最核心的部分:用代码实现贪吃蛇的规则。我们将创建一个GameState类来管理所有游戏状态。
4.1 游戏状态类(GameState)的设计
在game.py中,我们定义这个类:
# game.py import random class GameState: def __init__(self, board_width=400, board_height=300, grid_size=20): """ 初始化游戏状态。 :param board_width: 游戏区域宽度(像素) :param board_height: 游戏区域高度(像素) :param grid_size: 每个游戏格子的大小(像素),蛇和食物的大小 """ self.board_width = board_width self.board_height = board_height self.grid_size = grid_size # 计算网格行列数 self.grid_x = board_width // grid_size self.grid_y = board_height // grid_size # 初始化蛇:用一个列表存储身体各部分的网格坐标,列表头是蛇尾,列表尾是蛇头。 # 例如:[(1,1), (2,1), (3,1)] 表示蛇占据(1,1),(2,1),(3,1)三个格子,(3,1)是头。 start_x = self.grid_x // 2 start_y = self.grid_y // 2 self.snake = [(start_x, start_y), (start_x-1, start_y), (start_x-2, start_y)] # 初始长度为3,水平向左 self.direction = "RIGHT" # 蛇的当前移动方向 self.next_direction = "RIGHT" # 下一帧的方向(用于平滑处理按键) # 初始化食物 self.food = self._generate_food() # 游戏分数和状态 self.score = 0 self.game_over = False self.speed = 0.15 # 初始速度,单位秒(每次移动的间隔) def _generate_food(self): """在空白位置随机生成食物坐标。""" while True: food_x = random.randint(0, self.grid_x - 1) food_y = random.randint(0, self.grid_y - 1) food_pos = (food_x, food_y) # 确保食物不会生成在蛇的身体上 if food_pos not in self.snake: return food_pos def update_direction(self, new_dir): """更新蛇的移动方向,禁止直接反向。""" opposites = {"UP": "DOWN", "DOWN": "UP", "LEFT": "RIGHT", "RIGHT": "LEFT"} if new_dir != opposites.get(self.direction): self.next_direction = new_dir def move(self): """执行一帧的移动逻辑。""" # 1. 应用方向 self.direction = self.next_direction # 2. 根据当前方向计算新蛇头位置 head_x, head_y = self.snake[-1] # 当前蛇头 if self.direction == "UP": new_head = (head_x, head_y - 1) elif self.direction == "DOWN": new_head = (head_x, head_y + 1) elif self.direction == "LEFT": new_head = (head_x - 1, head_y) elif self.direction == "RIGHT": new_head = (head_x + 1, head_y) # 3. 碰撞检测:撞墙 if (new_head[0] < 0 or new_head[0] >= self.grid_x or new_head[1] < 0 or new_head[1] >= self.grid_y): self.game_over = True return # 4. 碰撞检测:撞自己(注意:新头部不能与除旧尾部外的任何身体部分重合) if new_head in self.snake[:-1]: # 检查新头是否在蛇身(不包括当前尾部)中 self.game_over = True return # 5. 将新头部加入蛇身 self.snake.append(new_head) # 6. 进食检测 if new_head == self.food: # 吃到食物,分数增加,生成新食物,不删除尾部(蛇变长) self.score += 10 self.food = self._generate_food() # 可选:随着分数增加,速度加快 self.speed = max(0.05, self.speed - 0.005) # 设置一个最低速度限制 else: # 没吃到食物,删除尾部(蛇保持长度移动) self.snake.pop(0) def get_draw_data(self): """返回绘图所需的数据。""" # 将网格坐标转换为像素坐标 snake_pixels = [(x * self.grid_size, y * self.grid_size) for (x, y) in self.snake] food_pixels = (self.food[0] * self.grid_size, self.food[1] * self.grid_size) return { "snake": snake_pixels, "food": food_pixels, "score": self.score, "game_over": self.game_over, "grid_size": self.grid_size }这个类封装了游戏的所有核心数据和行为。update_direction处理输入,move方法包含了移动、碰撞检测和进食的完整逻辑,get_draw_data则准备了绘图所需的信息,实现了逻辑与显示的分离。
4.2 绘图模块(Drawer)的实现
绘图模块负责将游戏状态可视化。我们在draw.py中实现:
# draw.py class Drawer: def __init__(self, gui, config): self.gui = gui self.config = config self.drawn_items = [] # 用于存储已绘制的图形对象ID,方便清空 def clear_screen(self): """清除所有由本Drawer绘制的图形。""" for item in self.drawn_items: self.gui.remove(item) # 从画布上移除对象 self.drawn_items.clear() # 或者更简单粗暴的方式:self.gui.clear() 清除所有gui绘制的内容 def draw_frame(self, game_data): """绘制完整的一帧画面。""" self.clear_screen() # 先清空旧画面 # 绘制蛇 snake_color = self.config.SNAKE_COLOR grid = game_data["grid_size"] for segment in game_data["snake"]: x, y = segment # 绘制一个填充的矩形作为蛇身 rect = self.gui.draw_rectangle(x, y, x+grid, y+grid, width=0, fill=snake_color) self.drawn_items.append(rect) # 绘制食物 food_color = self.config.FOOD_COLOR fx, fy = game_data["food"] food = self.gui.draw_oval(fx, fy, fx+grid, fy+grid, width=0, fill=food_color) self.drawn_items.append(food) # 绘制分数 score_text = f"Score: {game_data['score']}" text_obj = self.gui.draw_text(10, 10, text=score_text, font_size=20, color=self.config.TEXT_COLOR) self.drawn_items.append(text_obj) # 如果游戏结束,绘制结束画面 if game_data["game_over"]: self.draw_game_over(game_data['score']) def draw_game_over(self, final_score): """绘制游戏结束画面。""" # 半透明背景遮罩 overlay = self.gui.draw_rectangle(0, 0, self.config.WIDTH, self.config.HEIGHT, width=0, fill="black") self.gui.set_transparency(overlay, 0.7) # 设置透明度 self.drawn_items.append(overlay) # 游戏结束文字 go_text = self.gui.draw_text(self.config.WIDTH//2, self.config.HEIGHT//2 - 30, text="GAME OVER", font_size=40, color="red") self.drawn_items.append(go_text) # 最终分数 score_text = self.gui.draw_text(self.config.WIDTH//2, self.config.HEIGHT//2 + 30, text=f"Final Score: {final_score}", font_size=30, color="white") self.drawn_items.append(score_text) # 重新开始提示(可以结合触摸事件实现重新开始) restart_text = self.gui.draw_text(self.config.WIDTH//2, self.config.HEIGHT//2 + 80, text="Touch to Restart", font_size=25, color="yellow") self.drawn_items.append(restart_text) # 可以在这里绑定触摸事件:gui.bind(restart_text, restart_callback)绘图模块的关键在于draw_frame方法,它接收game_data字典,然后依次绘制蛇、食物、分数。使用drawn_items列表来管理图形对象,可以实现高效的局部更新(虽然这里用了清屏重绘,但对于简单游戏足够)。
4.3 主循环与事件处理的整合
最后,在main.py中,我们将所有模块串联起来,形成游戏的主循环。
# main.py (续) def main(): # ... 初始化代码(见3.3节) snake_game = game.GameState(board_width=config.WIDTH, board_height=config.HEIGHT, grid_size=config.GRID_SIZE) drawer = draw.Drawer(gui, config) # 方向控制函数,供按键事件调用 def go_up(): snake_game.update_direction("UP") def go_down(): snake_game.update_direction("DOWN") def go_left(): snake_game.update_direction("LEFT") def go_right(): snake_game.update_direction("RIGHT") # 绑定物理按键(假设行空板四个方向键连接到P21, P22, P23, P24) # 注意:实际引脚号请查阅你的行空板原理图 btn_up = Pin(Pin.P22, Pin.IN) btn_down = Pin(Pin.P23, Pin.IN) btn_left = Pin(Pin.P21, Pin.IN) btn_right = Pin(Pin.P24, Pin.IN) # 游戏主循环 while True: # 1. 处理输入(非阻塞式读取) # 这里采用轮询方式,也可以使用中断,但轮询对简单游戏足够 if btn_up.read_digital() == 0: time.sleep(0.02) # 简单防抖 if btn_up.read_digital() == 0: go_up() if btn_down.read_digital() == 0: time.sleep(0.02) if btn_down.read_digital() == 0: go_down() # ... 类似处理left和right # 2. 更新游戏逻辑(如果游戏未结束) if not snake_game.game_over: snake_game.move() # 获取当前帧的绘图数据 current_frame_data = snake_game.get_draw_data() # 3. 绘制画面 drawer.draw_frame(current_frame_data) else: # 游戏结束状态,可以闪烁“Game Over”或等待重启 # 这里简单处理,停止移动,画面停留在结束画面 pass # 4. 控制游戏速度 time.sleep(snake_game.speed) # 使用游戏状态中动态调整的速度 if __name__ == "__main__": main()这个主循环是游戏的核心引擎。它不断重复“读取输入 -> 更新状态 -> 绘制画面 -> 等待”这个过程。time.sleep(snake_game.speed)这一行至关重要,它控制了游戏的帧率(即蛇的移动速度)。速度值越小,循环越快,蛇移动得也越快。
5. 功能扩展与性能优化思路
一个基础版的贪吃蛇已经完成了。但要让游戏更有趣、更健壮,我们还可以进行很多扩展和优化。
5.1 增加游戏功能与交互
开始/暂停/重新开始菜单:
- 在游戏初始化时,不直接进入主循环,而是显示一个开始界面。可以用
gui.draw_text画出“Start”按钮,并用gui.bind绑定触摸事件。 - 在主循环中增加一个
game_paused状态变量。当暂停时,跳过snake_game.move(),只绘制静态画面和暂停提示。 - 游戏结束后,在结束画面上绑定“重新开始”触摸事件。事件处理函数需要重置
GameState对象的所有属性到初始值。
- 在游戏初始化时,不直接进入主循环,而是显示一个开始界面。可以用
难度选择:
- 在配置文件中预设几个速度档位(如
SPEED_EASY = 0.2,SPEED_HARD = 0.08)。 - 在开始菜单中让玩家选择,并将选择的速度值传递给
GameState的初始化。
- 在配置文件中预设几个速度档位(如
音效与震动反馈(利用行空板特性):
- 音效:行空板支持音频播放。你可以在吃到食物时播放一个简短的“beep”声,游戏结束时播放一段失败音效。可以使用
audio模块(如果支持)或调用系统命令播放WAV文件。 - 震动:有些行空板型号带有震动马达。通过
pinpong库控制一个GPIO口输出PWM信号,可以在蛇撞墙时触发短暂震动,增强体验。
- 音效:行空板支持音频播放。你可以在吃到食物时播放一个简短的“beep”声,游戏结束时播放一段失败音效。可以使用
游戏分数排行榜:
- 将分数和玩家名称(可以输入缩写)保存到行空板的一个文本文件(如
scores.txt)中。 - 游戏结束后读取文件,显示历史最高分前几名。这涉及到简单的文件读写操作。
- 将分数和玩家名称(可以输入缩写)保存到行空板的一个文本文件(如
5.2 代码优化与性能提升
当游戏逻辑变复杂,或者蛇身很长时,可能会遇到性能问题。以下是一些优化思路:
局部刷新代替全局清屏:我们目前的
draw_frame是清屏后重绘所有元素。对于贪吃蛇,只有蛇头、蛇尾和食物可能变化。可以优化为:- 只擦除上一帧的蛇尾(如果移动了)和旧食物。
- 绘制新蛇头和新食物。
- 分数文本如果变化,也只需更新文本内容(
gui.itemconfig(score_text_item, text=new_score)),而不是删除重绘。 - 这需要绘图模块更精细地记录每个图形对象对应的游戏元素。
使用双缓冲或画布(Canvas)对象:
unihiker的底层是tkinter,频繁的create和delete操作可能效率不高。更高级的做法是使用tkinter.Canvas,并在一个离屏的缓冲区上绘制好整个画面,然后一次性更新到屏幕上。不过对于贪吃蛇这种简单游戏,通常不需要这么复杂。优化碰撞检测算法:
- 我们目前用
if new_head in self.snake[:-1]来检测是否撞到自己。Python的in操作在列表上平均时间复杂度是O(n),当蛇身很长时(比如几百节),这会成为性能瓶颈。 - 优化方法:使用一个与游戏网格同样大小的二维布尔数组(或使用Python的
set集合)来记录蛇身占据的位置。移动时,更新这个集合。这样碰撞检测就变成了O(1)的操作。
# 在GameState.__init__中增加 self.snake_set = set(self.snake) # 使用集合存储蛇身坐标 # 在move方法中更新 # 吃到食物时,只添加新头 self.snake_set.add(new_head) # 没吃到食物时,先删除旧尾,再添加新头 self.snake_set.remove(self.snake[0]) self.snake_set.add(new_head) # 碰撞检测 if new_head in self.snake_set and new_head != self.snake[-1]: # 注意排除新头与旧尾重合的情况(当长度不变时) self.game_over = True- 我们目前用
主循环事件处理优化:目前的按键轮询在主循环中。如果游戏速度很快(
sleep时间很短),轮询频率就高,可能浪费资源。可以考虑使用中断(pinpong库可能支持)或者将按键检测放在一个独立的线程中,但要注意线程间的数据同步(如direction变量)。
6. 常见问题与调试技巧实录
在开发过程中,你几乎一定会遇到下面这些问题。这里记录了我的踩坑经验和解决方案。
6.1 蛇的移动方向控制失灵或反向“自杀”
- 问题现象:按下与当前方向相反的键,蛇立刻死亡。
- 原因分析:在
update_direction方法中,方向检查逻辑有误,或者在主循环中方向更新时机不对。例如,在一帧内,先读取了“左键”,蛇头向左移动了一格,紧接着在同一帧内又读取了“右键”,并立即将方向改为右,导致下一帧蛇头向右移动,撞上了刚刚向左移动后留下的身体。 - 解决方案:
- 确保方向检查:
update_direction中的反向禁止逻辑必须正确。 - 使用“缓冲方向”:这是我推荐的方法。如代码所示,我们设置一个
next_direction变量。按键只修改next_direction。在每一帧的move()开始时,才将next_direction赋值给direction。这样,无论一帧内按了多少次键,最终只生效最后一次有效的方向。这避免了同一帧内方向突变的问题。 - 按键防抖和节流:确保物理按键的防抖处理到位,避免一次按下被误判为多次。
- 确保方向检查:
6.2 食物生成在蛇身体内部
- 问题现象:新生成的食物看不见,或者蛇直接“穿过”食物没反应。
- 原因分析:
_generate_food函数中的循环可能陷入死循环(如果蛇身几乎占满整个屏幕),或者坐标判断逻辑有误。 - 解决方案:
- 增加安全计数器:在
while True循环中加入计数器,如果尝试生成次数超过网格总数(如grid_x * grid_y * 2),则判定为游戏胜利(蛇填满了屏幕),跳出循环。
def _generate_food(self): max_attempts = self.grid_x * self.grid_y * 2 for _ in range(max_attempts): food_x = random.randint(0, self.grid_x - 1) food_y = random.randint(0, self.grid_y - 1) food_pos = (food_x, food_y) if food_pos not in self.snake: return food_pos # 如果尝试这么多次都没找到空位,说明蛇已经很长了,可以返回一个默认值或触发胜利条件 return None # 并在调用处处理- 检查坐标转换:确保蛇身坐标列表
self.snake和食物坐标self.food都使用相同的坐标系(网格坐标)。绘图时再转换为像素坐标。
- 增加安全计数器:在
6.3 游戏画面闪烁或卡顿
- 问题现象:屏幕更新不流畅,有闪烁感。
- 原因分析:
- 清屏和重绘之间的延时:
clear_screen()和draw_frame()之间如果有耗时操作,屏幕会短暂空白。 - 主循环频率不稳定:
time.sleep()的时间不精确,或者循环内某些操作(如复杂的碰撞检测)耗时波动大。 - 绘图操作本身耗时:绘制大量图形对象(长蛇身)可能较慢。
- 清屏和重绘之间的延时:
- 解决方案:
- 减少不必要的清屏:尝试使用局部更新(见5.2节)。
- 固定帧率:使用更精确的定时方式。
time.sleep()精度较差。可以考虑使用time.perf_counter()来计算每一帧实际耗时,然后动态调整等待时间,使帧率更稳定。
import time FPS = 10 # 目标帧率 frame_duration = 1.0 / FPS while True: frame_start = time.perf_counter() # 处理输入、更新逻辑、绘制画面... frame_time = time.perf_counter() - frame_start sleep_time = frame_duration - frame_time if sleep_time > 0: time.sleep(sleep_time) # 如果frame_time已经超过frame_duration,说明这一帧超时了,就不睡眠直接进入下一帧- 简化图形:用简单的实心矩形代替带边框的复杂图形。减少屏幕上同时存在的图形对象数量。
6.4 行空板按键无反应或反应混乱
- 问题现象:按下板载按键,蛇没有反应,或者方向控制错乱。
- 原因分析:
- 引脚号错误:这是最常见的原因。行空板上的按键A、B对应的GPIO引脚号可能因版本而异。你必须查阅官方文档或原理图来确认。
- 上拉/下拉电阻配置:按键电路设计可能是低电平有效(按下为0)或高电平有效(按下为1)。我们的代码假设是低电平有效(
read_digital() == 0)。如果不对,需要调整判断逻辑。 - 主循环太快,错过按键:如果
time.sleep(snake_game.speed)中的speed值非常小(比如0.01),循环极快,按键按下的短暂时间可能落在两次读取之间,导致漏检。
- 排查步骤:
- 编写一个简单的按键测试程序,单独测试每个按键,打印出按下的状态和对应的引脚读数。这是硬件调试的基本功。
from pinpong.board import Board, Pin import time Board().begin() btn = Pin(Pin.P21, Pin.IN) # 假设是P21 while True: print(f"Pin P21 value: {btn.read_digital()}") time.sleep(0.5)- 根据测试结果,修正
main.py中的引脚号和电平判断逻辑。 - 如果仍有漏检,可以适当降低游戏速度,或者采用“状态记录”法:在按键事件中,只设置一个方向标志位,在主循环中统一处理这个标志位,而不是依赖瞬间的读取。
6.5 游戏结束后无法重新开始
- 问题现象:显示“Game Over”后,触摸屏幕没反应。
- 原因分析:游戏结束画面上的“Touch to Restart”文本只是一个绘图对象,没有绑定触摸事件,或者绑定的事件处理函数没有正确重置游戏状态。
- 解决方案:
- 在
draw_game_over方法中,为“重新开始”文本对象绑定事件。 - 事件处理函数需要做以下几件事:
- 重置
GameState对象(或创建一个新的)。 - 清空
drawn_items列表。 - 将游戏主循环的状态变量(如
game_over)复位。 - 重新开始主循环(或跳转到游戏开始状态)。 由于行空板
unihiker的事件绑定是在GUI线程中,而主循环是阻塞的,这里可能需要稍微调整程序结构,例如将主循环封装在一个函数中,游戏结束后退出循环,触摸事件触发时再次调用这个函数。
- 重置
- 在
调试是一个迭代的过程。我的习惯是,每写一小段功能就运行测试一下,比如先让蛇能移动,再添加食物,最后处理碰撞。使用print()语句在关键位置输出变量状态(如蛇头坐标、方向、食物坐标),是定位问题最简单有效的方法。行空板连接电脑后,打印信息会显示在串口终端里,这是你最好的调试伙伴。