1. 从零手写贪吃蛇:为什么这个老项目值得反复练
贪吃蛇这个游戏,几乎每个学编程的人都动过手。但有意思的是,同样一个贪吃蛇,有人写出来两百行跑得稳稳当当,有人写出来一千行还到处是bug。差别不在天赋,在于有没有想清楚几个核心问题:蛇怎么移动、食物怎么生成、碰撞怎么判定、速度怎么控制。这几个问题想透了,贪吃蛇就是一个非常扎实的编程练习项目。
我见过太多初学者一上来就急着写代码,结果写到一半发现蛇的移动逻辑根本理不顺,只能推倒重来。其实贪吃蛇的难点从来不是代码量,而是数据结构的选型和游戏循环的设计。你把这两件事想明白了,剩下的就是体力活。
这篇文章适合谁看?如果你正在做课程设计、实验项目,或者单纯想找一个能完整跑通的小项目来练手,那这篇内容可以直接拿去参考。我会从设计思路讲到具体实现,把每个关键决策背后的理由都说清楚,代码部分用Python来演示,因为它的语法足够简洁,能把注意力集中在逻辑本身而不是语言细节上。
需要提前说明的是,下面涉及的具体实现方案是基于常见教学实践和工程经验做的合理设计,不是唯一解。你可以根据自己的技术栈和需求做调整,但核心思路是通用的。
2. 动手之前先想清楚:贪吃蛇的四个核心问题
2.1 蛇的身体到底用什么数据结构存
这是第一个要拍板的事。很多人下意识会用列表,因为蛇看起来就是一节一节连着的。用列表确实可以,但你要想清楚一个问题:蛇移动的时候,到底发生了什么?
蛇移动的本质是:头部新增一节,尾部删掉一节(没吃到食物时)。如果用列表,头部在索引0,尾部在索引-1,那每次移动就是snake.insert(0, new_head)然后snake.pop()。这个操作在Python里是O(n)的复杂度,因为insert要移动所有元素。蛇短的时候无所谓,蛇长到几百节的时候就会感觉到卡顿。
更优雅的方案是用collections.deque(双端队列)。它支持O(1)的两端插入和删除,正好匹配蛇的移动模式。代码写起来也直观:
from collections import deque snake = deque([(5, 5), (5, 4), (5, 3)]) # 头在左边 # 移动:头部加一节,尾部删一节 snake.appendleft(new_head) if not ate_food: snake.pop()提示:如果你用的是其他语言,思路是一样的——找一个支持两端高效操作的数据结构。C++用
std::deque,Java用ArrayDeque,JavaScript用数组配合unshift和pop也能凑合,但性能不如前两者。
还有一个细节:蛇的身体坐标要不要去重?正常情况下不会重复,但如果你的移动逻辑写错了,蛇头可能撞到自己身上,这时候去重反而会掩盖bug。所以我的建议是不去重,让碰撞检测自然暴露问题。
2.2 游戏循环用固定帧率还是动态刷新
游戏循环是整个程序的心脏。最简单的写法是while True里面加一个time.sleep(0.1),每轮循环处理一次输入、更新一次状态、重绘一次画面。这种写法叫固定帧率循环,优点是简单直观,缺点是速度不可调——你想让蛇随分数增加而加速,就得动态改sleep的时间。
另一种写法是记录上一帧的时间戳,每帧计算距离上次更新过了多久,只有超过设定的间隔才更新逻辑。这种写法叫时间驱动循环,更灵活,但代码稍微复杂一点。
对于贪吃蛇这种回合制的游戏(本质上蛇是一格一格跳的,不是连续移动的),固定帧率循环完全够用。我推荐用pygame.time.Clock来控制帧率,它比裸的time.sleep更精确:
import pygame clock = pygame.time.Clock() base_speed = 8 # 每秒移动8格 while running: clock.tick(base_speed + score // 5) # 每得5分加速1格 handle_input() update_game_state() draw()这里有个容易踩的坑:clock.tick()的参数是每秒帧数,不是每帧毫秒数。很多人第一次用会搞混,写成clock.tick(100)以为是一百毫秒一帧,结果蛇飞得根本看不清。
2.3 食物生成怎么保证不刷在蛇身上
食物生成看起来简单,随机一个坐标就行了。但你要保证这个坐标不在蛇身上,否则蛇一吃食物就撞自己,游戏直接结束。
最朴素的写法是循环随机,直到随机到一个不在蛇身上的位置:
import random def generate_food(snake, grid_width, grid_height): while True: pos = (random.randint(0, grid_width - 1), random.randint(0, grid_height - 1)) if pos not in snake: return pos这个写法在蛇短的时候没问题,但蛇长到占满大半个屏幕的时候,循环可能要跑很多次才能找到一个空位,极端情况下甚至死循环。更稳妥的做法是先收集所有空格子,再从中随机选一个:
def generate_food(snake, grid_width, grid_height): all_cells = {(x, y) for x in range(grid_width) for y in range(grid_height)} empty_cells = all_cells - set(snake) if not empty_cells: return None # 蛇已经占满整个屏幕,通关了 return random.choice(list(empty_cells))这个写法虽然每次都要遍历整个网格,但对于贪吃蛇这种小规模场景(通常20x20到40x40),性能完全不是问题。而且它天然处理了"蛇占满屏幕"的边界情况,逻辑更完整。
2.4 碰撞检测的边界条件有哪些
碰撞检测分两种:撞墙和撞自己。
撞墙很简单,蛇头的坐标超出网格范围就结束。但要注意,如果你的网格是20x20,坐标范围是0到19,那判断条件应该是x < 0 or x >= 20 or y < 0 or y >= 20。很多人会写成x > 20,结果蛇头跑到坐标20的时候没触发碰撞,直接穿墙了。
撞自己稍微麻烦一点。因为蛇头移动后,如果没吃到食物,蛇尾会同时删掉,所以蛇头有可能移动到原来蛇尾的位置而不算撞到自己。这个细节很多人会忽略:
new_head = (snake[0][0] + dx, snake[0][1] + dy) # 先判断是否吃到食物 ate_food = (new_head == food) # 撞自己:如果没吃到食物,蛇尾会移走,所以不算撞 body_to_check = snake if ate_food else list(snake)[:-1] if new_head in body_to_check: game_over()这个逻辑如果你不仔细想,很容易写出"蛇头撞到蛇尾就死"的bug,玩家会觉得莫名其妙。
3. 一步步把代码搭起来:从窗口到可玩的完整过程
3.1 环境准备与项目骨架
用Python写贪吃蛇,最常用的库是pygame。安装很简单:
pip install pygame如果你不想装第三方库,用tkinter也能做,但画面刷新和键盘响应会麻烦一些。pygame的优势在于它把游戏循环、事件处理、图形绘制都封装好了,你只需要关注游戏逻辑。
项目骨架我建议分成三块:配置常量、游戏状态、主循环。配置常量放最上面,方便调整:
# 配置 GRID_SIZE = 20 # 网格大小 20x20 CELL_SIZE = 30 # 每个格子30像素 WINDOW_WIDTH = GRID_SIZE * CELL_SIZE WINDOW_HEIGHT = GRID_SIZE * CELL_SIZE FPS_BASE = 8 # 基础速度 COLOR_BG = (30, 30, 30) COLOR_SNAKE = (0, 200, 100) COLOR_FOOD = (220, 60, 60) COLOR_TEXT = (255, 255, 255)游戏状态用一个类来管理,把蛇、食物、分数、方向、是否结束都放进去:
class GameState: def __init__(self): self.snake = deque([(10, 10), (10, 9), (10, 8)]) self.direction = (0, 1) # 向右 self.next_direction = (0, 1) self.food = generate_food(self.snake, GRID_SIZE, GRID_SIZE) self.score = 0 self.game_over = False这里有个关键设计:当前方向和下一步方向分开存。为什么?因为玩家可能在一帧内快速按了两个方向键,如果直接改direction,蛇可能在同一帧内先向右再向下,直接撞到自己。用next_direction缓冲一下,每帧只应用一次方向变更,就能避免这个问题。
3.2 蛇的移动逻辑与方向缓冲
方向缓冲的具体实现是这样的:
def handle_input(self, event): if event.type == pygame.KEYDOWN: if event.key == pygame.K_UP and self.direction != (0, 1): self.next_direction = (0, -1) elif event.key == pygame.K_DOWN and self.direction != (0, -1): self.next_direction = (0, 1) elif event.key == pygame.K_LEFT and self.direction != (1, 0): self.next_direction = (-1, 0) elif event.key == pygame.K_RIGHT and self.direction != (-1, 0): self.next_direction = (1, 0)注意每个方向判断里都有一个self.direction !=的条件。这是防止蛇直接掉头——比如蛇正在向右走,玩家按左键,如果不拦着,蛇头会直接撞到自己的脖子上,瞬间游戏结束。这个体验非常糟糕,玩家会觉得是游戏的问题而不是自己的问题。
然后在每帧更新的时候,把next_direction应用到direction:
def update(self): if self.game_over: return self.direction = self.next_direction head_x, head_y = self.snake[0] dx, dy = self.direction new_head = (head_x + dx, head_y + dy) # 撞墙检测 if not (0 <= new_head[0] < GRID_SIZE and 0 <= new_head[1] < GRID_SIZE): self.game_over = True return # 吃食物检测 ate_food = (new_head == self.food) # 撞自己检测 body = self.snake if ate_food else list(self.snake)[:-1] if new_head in body: self.game_over = True return self.snake.appendleft(new_head) if ate_food: self.score += 1 self.food = generate_food(self.snake, GRID_SIZE, GRID_SIZE) else: self.snake.pop()这段代码是整个游戏的核心,每一行都有存在的理由。我建议你逐行理解,不要直接复制粘贴。特别是撞自己检测里那个body的取值逻辑,是很多人写错的地方。
3.3 绘制与帧率控制的配合
绘制部分用pygame的draw.rect就够了。蛇头用深一点的颜色区分,食物画成圆形或者方块都行:
def draw(screen, state): screen.fill(COLOR_BG) # 画蛇 for i, (x, y) in enumerate(state.snake): color = COLOR_SNAKE if i > 0 else (0, 255, 130) rect = pygame.Rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE - 2, CELL_SIZE - 2) pygame.draw.rect(screen, color, rect) # 画食物 fx, fy = state.food center = (fx * CELL_SIZE + CELL_SIZE // 2, fy * CELL_SIZE + CELL_SIZE // 2) pygame.draw.circle(screen, COLOR_FOOD, center, CELL_SIZE // 2 - 2) # 画分数 font = pygame.font.SysFont(None, 36) text = font.render(f"Score: {state.score}", True, COLOR_TEXT) screen.blit(text, (10, 10)) if state.game_over: over_text = font.render("Game Over - Press R to Restart", True, COLOR_TEXT) screen.blit(over_text, (WINDOW_WIDTH // 2 - 180, WINDOW_HEIGHT // 2)) pygame.display.flip()帧率控制放在主循环里,根据分数动态调整:
def main(): pygame.init() screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) pygame.display.set_caption("Snake") clock = pygame.time.Clock() state = GameState() running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_r and state.game_over: state = GameState() else: state.handle_input(event) state.update() draw(screen, state) # 速度随分数提升,但设一个上限 speed = min(FPS_BASE + state.score // 5, 25) clock.tick(speed) pygame.quit()速度上限很重要。如果不设上限,分数高了之后蛇会快到人类反应不过来,游戏体验直接崩掉。25帧每秒大概是一秒移动25格,对于20x20的网格来说已经非常快了。
4. 跑通之后再说优化:让贪吃蛇更像一个"作品"
4.1 手感调优:速度曲线和输入响应
基础版本跑通之后,你会发现一个问题:蛇的速度是阶梯式跳变的,每得5分突然快一截,玩起来很突兀。更好的做法是用平滑的速度曲线,比如用对数或者平方根函数:
import math def get_speed(score): base = 8 max_speed = 22 # 用对数曲线,前期加速快,后期趋于平缓 speed = base + (max_speed - base) * math.log(1 + score / 10) / math.log(1 + 100 / 10) return min(speed, max_speed)这样蛇的速度会随着分数增加而平滑提升,玩家几乎感觉不到突变,但整体难度确实在上升。
另一个手感问题是输入响应。固定帧率循环下,玩家的按键只在每帧更新时被处理一次。如果帧率是8,那一秒只有8次响应机会,快速连按可能会丢输入。解决办法是在事件循环里缓存所有按键,每帧只取最后一个有效方向:
def handle_input(self, event): if event.type == pygame.KEYDOWN: new_dir = None if event.key == pygame.K_UP: new_dir = (0, -1) elif event.key == pygame.K_DOWN: new_dir = (0, 1) elif event.key == pygame.K_LEFT: new_dir = (-1, 0) elif event.key == pygame.K_RIGHT: new_dir = (1, 0) if new_dir and new_dir != (-self.direction[0], -self.direction[1]): self.next_direction = new_dir这样即使玩家在一帧内按了多个方向,也只会应用最后一个合法方向,不会丢输入也不会掉头。
4.2 视觉与音效的加分项
基础版的贪吃蛇就是一堆方块,能玩但不好看。加几个小细节就能让它看起来专业很多:
- 蛇身渐变:蛇头到蛇尾颜色逐渐变暗,视觉上更有层次感
- 食物呼吸效果:食物的大小随时间轻微缩放,吸引注意力
- 吃到食物的反馈:屏幕轻微震动或者食物位置爆出一圈粒子
- 网格背景:画一层淡淡的网格线,让玩家更容易判断距离
蛇身渐变的实现很简单,根据索引计算颜色插值:
def get_snake_color(index, total): ratio = index / max(total - 1, 1) r = int(0 + (0 - 0) * ratio) g = int(255 - (255 - 100) * ratio) b = int(130 - (130 - 50) * ratio) return (r, g, b)音效方面,pygame.mixer可以加载简单的音效文件。吃到食物播放一个短促的"叮",游戏结束播放一个低沉的"嗡"。注意音效文件要短,超过0.5秒就会觉得拖沓。
4.3 从单文件到可维护结构的拆分思路
如果你只是交个实验作业,单文件完全够用。但如果你想把这个项目当成一个正经的练手作品,建议拆成几个模块:
| 模块 | 职责 | 对应文件 |
|---|---|---|
| 配置 | 常量、颜色、速度参数 | config.py |
| 游戏状态 | 蛇、食物、分数、更新逻辑 | game_state.py |
| 渲染 | 绘制蛇、食物、UI | renderer.py |
| 输入 | 键盘事件处理 | input_handler.py |
| 主循环 | 组装以上模块 | main.py |
拆分的意义不在于代码量,而在于每个模块可以独立测试。比如你可以单独测试game_state.update()的逻辑,不用启动整个游戏窗口。这对于调试碰撞检测这种容易出错的逻辑非常有帮助。
拆分的时候有一个原则:状态和渲染分离。game_state只负责逻辑,不碰任何pygame.draw的调用;renderer只负责画,不修改任何游戏状态。这样你以后想换渲染方式(比如换成终端字符画),只需要重写renderer,逻辑代码一行不用动。
5. 那些年我踩过的坑:贪吃蛇开发中的典型问题排查
5.1 蛇移动时的"拖影"和"瞬移"问题
这是新手最常见的问题:蛇移动的时候看起来像在瞬移,或者尾部有拖影。根本原因通常是绘制和更新顺序搞反了。
正确的顺序是:先处理输入,再更新状态,最后绘制。如果你先绘制再更新,画面上显示的就是上一帧的状态,看起来就会慢半拍。如果你在更新状态之前就清了屏幕,但绘制的时候又用了旧的状态,就会出现拖影。
还有一个可能是帧率设置得太高。如果你用clock.tick(60),蛇一秒移动60格,在20x20的网格上几乎是一瞬间就穿过去了。贪吃蛇的合适帧率是8到15之间,超过20就会觉得太快。
排查方法很简单:在每帧更新后打印蛇头坐标,看看是不是每帧只移动了一格。如果一帧移动了多格,说明你的更新逻辑里有多余的循环或者帧率计算有问题。
5.2 食物生成在蛇身上的死循环
前面提到过,用循环随机的方式生成食物,在蛇很长的时候可能卡死。我实际遇到过的情况是:蛇占了380个格子(20x20的网格总共400格),只剩20个空格,循环随机平均要跑20次才能找到一个空位。如果蛇占了399格,平均要跑400次。虽然概率低,但一旦发生,游戏就会卡住不动。
更隐蔽的问题是:如果蛇占满了整个屏幕,循环随机会永远找不到空位,直接死循环。玩家看到的就是游戏突然卡死,没有任何提示。
解决方案就是前面说的"先收集空格再随机选"。这个方案还有一个好处:你可以在蛇占满屏幕时触发"通关"逻辑,给玩家一个胜利画面,而不是卡死。
5.3 快速按键导致的方向冲突
这个问题的表现是:玩家快速按"上"然后按"右",蛇有时候会直接撞墙或者撞自己。原因是两个按键在同一帧内被处理,direction被连续改了两次,蛇头实际移动的方向和玩家预期的不一致。
比如蛇正在向右走,玩家快速按了"上"和"左"。如果两个按键在同一帧被处理,direction先变成"上",再变成"左"。但"左"相对于"上"是合法的(不是掉头),所以蛇会向左走。但玩家预期的是先向上再向左,蛇应该走一个L形。结果蛇直接向左,可能就撞到了自己的身体。
用next_direction缓冲就能解决这个问题:每帧只应用一次方向变更,快速按键的效果会分散到多帧,蛇的移动轨迹就和玩家预期一致了。
5.4 游戏结束后状态没有正确重置
按R重启的时候,很多人只重置了蛇和分数,忘了重置方向、食物和游戏结束标志。结果重启后蛇的方向还是上一局结束时的方向,或者食物还停留在上一局的位置。
正确的重置方式是重新创建一个GameState对象,而不是逐个重置字段。这样能保证所有状态都回到初始值,不会遗漏:
if event.key == pygame.K_r and state.game_over: state = GameState() # 直接新建,不要逐个重置这个习惯在更复杂的项目里尤其重要。手动重置字段迟早会漏掉某个,而新建对象是天然完整的。
6. 从贪吃蛇延伸出去:还能练什么
贪吃蛇写完之后,如果你觉得还有余力,可以试试这几个扩展方向。每一个都能让你练到不同的编程技能。
加障碍物:在网格里随机放一些墙壁,蛇撞到墙壁就结束。这个扩展练的是地图生成和碰撞检测的扩展。你可以设计几种不同的障碍物布局,让游戏有多个关卡。
双人对战:两个玩家用不同的按键控制两条蛇,谁先撞到对方或者撞墙谁就输。这个扩展练的是多对象状态管理和更复杂的碰撞逻辑。两条蛇的移动、吃食物、碰撞检测都要独立处理,代码量会翻倍,但思路是一样的。
AI自动寻路:写一个简单的AI来控制蛇,用BFS或者A*算法找最短路径去吃食物。这个扩展练的是算法实现,而且很有意思——你可以让AI和人类对战,看看谁更强。AI的难点在于要避免走进死胡同,简单的贪心算法很容易把自己困住。
存档与回放:把每一帧的蛇的状态记录下来,游戏结束后可以回放整局游戏。这个扩展练的是数据序列化和状态管理。你可以把回放数据存成JSON文件,也可以做成二进制格式来练练文件IO。
每个扩展方向都可以独立做,也可以组合起来。我的建议是先把基础版写扎实,再挑一个最感兴趣的扩展来做。贪吃蛇这个项目的价值不在于它本身有多复杂,而在于它是一个可以无限叠加复杂度的载体。你每加一个功能,都会遇到新的问题,解决这些问题的过程就是最好的练习。
代码写完之后,我建议你至少完整玩十局。很多逻辑上的小问题,只有真正玩的时候才会暴露出来。比如蛇在边界处的碰撞判定、快速转向时的手感、食物生成的位置是否合理,这些都不是看代码能看出来的,必须上手玩。玩的过程中把不舒服的地方记下来,然后逐个优化,这个迭代过程比写第一版代码更有价值。