资讯详情

控制台贪吃蛇从能跑到能玩:环形数组、状态机与定点重绘实战

📅 2026/10/23 19:19:01 | 华诺云谱 👁 阅读
控制台贪吃蛇从能跑到能玩:环形数组、状态机与定点重绘实战
上一篇文章我们把控制台贪吃蛇最基础的版本跑通了蛇能动、食物能刷、撞墙能结束。但那个版本距离“一个能玩的游戏”还差得远。如果你真把它拿给朋友试玩多半会被吐槽三件事按键卡顿、画面闪烁、蛇稍微一长就找不着头在哪。这篇就接着上一篇往下写目标很实在把控制台贪吃蛇从“能跑”升级到“能玩”顺便把代码结构理顺让后面再加功能不会越改越乱。文中代码我用C/C混合风格写Windows控制台环境可以直接用拿到Linux下把conio.h相关部分换成termios也能跑通。1. 从第一版到第二版这次改造的核心思路1.1 第一版留下的三个坑第一版贪吃蛇的代码结构估计写过的人都差不多一个while(true)主循环循环里读键盘、更新蛇头坐标、用system(cls)清屏再重新画整个地图。优点是逻辑直白两三屏代码就能把“蛇会动、能吃食物、会撞墙”讲完作为第一篇的收尾绰绰有余。但如果你照着那个版本继续加功能很快会碰到三堵墙。第一堵墙是画面闪烁。system(cls)每次调用都会把整个控制台缓冲区清掉然后你再重新输出几十上百个字符。操作一多光标来回跳动屏幕上就像按了频闪灯一样。蛇短的时候还好蛇身长到二三十节每一帧要重绘太多字符闪烁感会明显到让人不想玩。第二堵墙是速度控制不准。第一版最常用Sleep(interval)控制每一帧的间隔但这个写法有个隐藏问题Sleep只保证“至少等这么多毫秒”它不计入游戏逻辑本身的耗时。你移动蛇、绘制画面、读取按键都要花时间把这些时间加上去实际每帧间隔会比设定的interval长而且长短不稳定。结果就是蛇的速度忽快忽慢吃食物加速的逻辑也没法做准。第三堵墙是数据结构太脆弱。很多人习惯用std::vector存蛇身每次移动先insert新头再pop_back删尾巴。功能上没问题但所有逻辑全堆在主循环里代码长了之后哪里动了蛇身、哪里改了状态根本理不清。第二篇我不想急着上多复杂的算法先把这些问题解决掉后面写计分、菜单、难度才会轻松。1.2 第二版的目标与功能清单这次改造我给自己列了五条硬性目标蛇身用固定数组加环形队列思想来维护彻底摆脱频繁的内存操作放弃system(cls)改成光标定位加定点重绘消灭闪烁引入固定时间戳计时替代Sleep让速度控制可预期加入菜单、暂停、结束、再来一局用状态机管理游戏流程增加计分和难度递进让游戏有“越玩越难”的反馈感。这五条做完这个项目就不再是“教学Demo”了它更像一个可以拿来消遣、可以继续扩展的小成品。下面我按模块拆开讲每段都会给出关键代码和踩坑说明。2. 蛇身数据结构环形数组与队列思想2.1 为什么蛇的移动本质上是个队列贪吃蛇每走一步做的事情可以拆成两步看先算出新蛇头要去的位置如果新位置不是食物就把尾巴那一节删掉。这正好符合队列“头进尾出”的模型。用std::deque当然能做但控制台项目里我更推荐用定长数组加head/tail两个下标来模拟理由有两个第一你不需要引入STL容器纯C环境也能跑方便理解底层原理第二环形数组的访问是O(1)的画蛇身也只需要从尾巴遍历到头部代码非常直观。先定义一下蛇的结构体const int MAX_BODY 500; // 地图最多能容纳的蛇身节数 struct Snake { int x[MAX_BODY]; int y[MAX_BODY]; int head; // 蛇头所在数组下标 int tail; // 蛇尾所在数组下标 int len; // 当前蛇身长度 };初始化时我们让蛇头朝右从(startX, startY)开始往左摆两节身体。这里有个容易绕晕的细节环形数组的“头”和“尾”不是固定在0号下标而是靠head和tail两个游标动态移动。void initSnake(Snake s, int startX, int startY) { // 蛇身三节从右往左排 s.x[0] startX 2; s.y[0] startY; // 最后一节尾巴 s.x[1] startX 1; s.y[1] startY; s.x[2] startX; s.y[2] startY; // 蛇头 s.tail 0; s.head 2; s.len 3; }2.2 入队与出队新头进尾巴出移动时新蛇头应该写到紧挨着当前head的下一个位置也就是(head 1) % MAX_BODY。取模的意思是数组下标到末尾时就回绕到开头形成一个环。这里还要区分两种情况吃到食物时蛇身要增长尾巴不动没吃到食物时尾巴跟着前进一格保持长度不变。void moveSnake(Snake s, int newX, int newY, bool grow) { int newHead (s.head 1) % MAX_BODY; s.x[newHead] newX; s.y[newHead] newY; s.head newHead; if (!grow) { // 没吃东西尾巴往前挪一格 s.tail (s.tail 1) % MAX_BODY; } else { s.len; } }这段代码是整条游戏的“发动机”逻辑很简单但写的时候必须注意顺序。我之前犯过把tail更新放在head更新之前的错结果新头写入还没完成尾巴先动了下一次画蛇身时坐标就对不上画面直接乱掉。2.3 画蛇身从尾巴遍历到头部绘制蛇身时从tail开始依次访问(tail 1) % MAX_BODY、(tail 2) % MAX_BODY……直到遍历完len个节点。每次取模都保证下标不会越界。void drawSnake(const Snake s) { int idx s.tail; for (int i 0; i s.len; i) { gotoxy(s.x[idx], s.y[idx]); SetConsoleTextAttribute(handle, 10); // 绿色 std::cout ■; idx (idx 1) % MAX_BODY; } }注意用■这类全角字符时一个字符占两个字节控制台光标定位必须落在正确坐标上否则画出来的蛇身会出现错位。建议把地图边界和蛇身都按“字符数”来算坐标不要和字节数混淆。2.4 判断蛇头是否撞到自己自碰检测是贪吃蛇最容易写错的地方。直觉做法是遍历整条蛇身看新头坐标是否和某一节重叠。但有一个特殊情况如果新头正好落在尾巴即将移开的位置那不算撞到自己因为这一节马上要被出队了。所以正确的遍历范围是“除了尾巴以外的所有节点”或者换个思路先判断没吃到食物时尾巴会先移走再检查新头是否与剩余身体重叠。bool hitSelf(const Snake s, int nx, int ny, bool grow) { int count grow ? s.len : s.len - 1; int idx s.tail; for (int i 0; i count; i) { if (s.x[idx] nx s.y[idx] ny) { return true; } idx (idx 1) % MAX_BODY; } return false; }这个“是否排除尾巴”的细节很多人在第一次写时都会漏。如果你发现蛇总是莫名其妙死在“明明尾巴要离开的位置”多半就是这里的问题。3. 输入控制与渲染优化手感提升的关键3.1 方向键的读取两个字节的坑Windows控制台下方向键不是普通字符按下后会返回两个字节第一个字节是224或0第二个才是真正表示方向的扫描码。只用_getch()读一次会拿到224直接判断方向必然失败。我见过不少人卡在这个问题上一两个小时其实处理起来很简单int readDirection() { if (!_kbhit()) return -1; int key _getch(); if (key 224 || key 0) { key _getch(); // 再读一次拿到真实按键 } switch (key) { case 72: return DIR_UP; case 80: return DIR_DOWN; case 75: return DIR_LEFT; case 77: return DIR_RIGHT; default: return -1; } }如果你在Linux下做移植方向键序列是\x1B[A这样的转义序列也是三个字节原理相通。3.2 防反转用异或判断正反方向贪吃蛇最经典的限制不能直接掉头。当前正在向右走的时候玩家按左键应该被忽略。如果不用任何技巧你得写四个if判断然后又臭又长。这里有个很聪明的位运算方法。给方向编号enum Direction { DIR_UP 0, // 上 DIR_DOWN 1, // 下 DIR_LEFT 2, // 左 DIR_RIGHT 3 // 右 };巧合的是0 ^ 1 11 ^ 1 02 ^ 1 33 ^ 1 2。也就是说上下相对、左右相对恰好可以用dir ^ 1表示“反方向”。判断时可以这么写int newDir readDirection(); if (newDir ! -1 newDir ! (curDir ^ 1)) { curDir newDir; }这个写法不管当前方向是哪个只要新方向是当前方向的反向就一定等于curDir ^ 1直接忽略即可。代码整洁逻辑也不容易出错。3.3 摆脱system(cls)光标定位与定点重绘第二版最重要的一步是把整屏重绘改成局点更新。既然蛇每次移动只影响三个位置新蛇头、旧蛇尾、可能变化的新食物那理论上每帧只需要在这些位置上做擦除和重画。Windows下定位光标用SetConsoleCursorPositionvoid gotoxy(int x, int y) { COORD pos; pos.X x; pos.Y y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos); }再配合一个隐藏光标函数视觉效果会干净很多void hideCursor() { CONSOLE_CURSOR_INFO cci; GetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), cci); cci.bVisible FALSE; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), cci); }有了这两个函数移动蛇的时候就可以精准操作// 每次update之后 // 1. 画新蛇头 gotoxy(newX, newY); SetConsoleTextAttribute(handle, 10); std::cout ■; // 2. 如果没吃到食物擦掉旧尾巴 if (!ate) { gotoxy(oldTailX, oldTailY); std::cout ; // 两个空格相当于擦除 } // 3. 如果吃到食物画新食物位置变了 if (ate) { drawFood(); }这种方法下画面不再全书刷新闪烁几乎消失蛇长到50节也不会有视觉卡顿。3.4 固定时间戳让速度控制真正可控用Sleep(interval)控制帧率的最大问题是它只负责“睡”不负责“校准”。假设每帧游戏逻辑要跑5毫秒你设置Sleep(100)实际每帧间隔就是105毫秒误差会累积蛇会越走越快或者忽快忽慢。解决办法是记录上一次更新的时间戳每次循环检查当前时间是否已经超过固定间隔DWORD lastTick GetTickCount(); int interval 150; // 毫秒 while (running) { DWORD now GetTickCount(); if (now - lastTick interval) { updateGame(); lastTick now; } render(); // 每帧都渲染保证按键响应及时 }注意render()不能放在if块里。因为按键响应需要在空闲时间也能被处理如果把渲染也卡进定时逻辑按一下方向键可能要等一个间隔周期才有反应手感非常差。暂停功能实现时记得做一件事恢复暂停的瞬间把lastTick重新赋值为GetTickCount()。否则暂停期间流逝的时间会被算进去一恢复游戏蛇就往前猛跳一大步。4. 游戏状态机与完整项目骨架4.1 为什么要把游戏状态拆开第一版把所有逻辑写在一个循环里菜单、暂停、结束全都塞进去会让代码变成一团乱麻。更好的做法是抽象出一个“状态机”让程序知道自己当前在哪个界面并根据状态决定该执行哪些逻辑。定义状态enum GameState { STATE_MENU 0, // 主菜单 STATE_RUN, // 游戏中 STATE_PAUSE, // 暂停 STATE_OVER // 游戏结束 };主循环变成这样int main() { initGame(); while (true) { switch (state) { case STATE_MENU: handleMenu(); break; case STATE_RUN: handleRun(); break; case STATE_PAUSE: handlePause(); break; case STATE_OVER: handleOver(); break; } } return 0; }每次只处理当前状态下的按键和绘制逻辑边界非常清晰。菜单里按空格进入游戏游戏中按空格暂停暂停中按空格恢复结束后按R重新开始。状态切换通过简单的赋值实现。4.2 菜单、暂停、结束三个辅助界面菜单界面可以画一个简单的标题和选项void drawMenu() { gotoxy(MAP_WIDTH / 2 - 5, MAP_HEIGHT / 2 - 2); std::cout 贪吃蛇 v2.0; gotoxy(MAP_WIDTH / 2 - 8, MAP_HEIGHT / 2); std::cout 1. 简单 2. 普通 3. 困难; gotoxy(MAP_WIDTH / 2 - 6, MAP_HEIGHT / 2 1); std::cout 空格键开始; }这里顺便把难度选择做了按下1、2、3分别把interval设为200、150、100对应慢、中、快三档。菜单事件处理放在handleMenu()里读取按键并修改全局状态。暂停界面不用动地图只需要在画面中央显示“暂停中”几个字然后等待空格键。注意暂停时必须保留当前的地图画面不能清除否则玩家恢复后看不到蛇在哪里。结束界面可以显示最终得分并提示按R重新开始。这里有两个细节一个是重新开始前要重新初始化所有全局变量另一个是必须把lastTick也重置否则新游戏第一帧会立刻触发一次更新。4.3 计分与难度递进计分规则很简单每吃一个食物加10分。在右侧画一个记分板用刚才的gotoxy精确绘制。这里有个容易忽略的地方记分板区域和游戏地图区域最好分开管理比如地图占左边60列记分板占右边20列避免坐标重叠。难度递进我用“分数每增加50分移动间隔减少10毫秒”的规则同时设置一个下限防止蛇快得完全没法操作if (score 0 score % 50 0 interval 60) { interval - 10; }实际体验下来60毫秒是普通玩家能接受的极限再快就需要用到“预测下一个按键”的技巧了。5. 实操过程搭一个能玩的控制台版本5.1 关键变量和初始化流程为了保证所有函数都能访问到需要的数据我建议用全局变量组织核心数据数量不多可控Snake snake; int foodX, foodY; int score; int curDir; int interval; GameState state; DWORD lastTick; HANDLE handle;初始化时依次做获取控制台句柄、隐藏光标、初始化蛇的位置、随机生成第一个食物、设置初始状态为菜单。随机函数记得用srand((unsigned)time(NULL))给种子否则每次运行食物位置都一样。5.2 游戏循环的update实现handleRun()是核心函数里面做三件事读方向、判断是否该更新、更新画面。void handleRun() { int newDir readDirection(); if (newDir ! -1 newDir ! (curDir ^ 1)) { curDir newDir; } DWORD now GetTickCount(); if (now - lastTick (DWORD)interval) { return; // 时间没到只处理按键 } lastTick now; // 计算新头位置 int dx[4] {0, 0, -1, 1}; int dy[4] {-1, 1, 0, 0}; int nx snake.x[snake.head] dx[curDir]; int ny snake.y[snake.head] dy[curDir]; // 撞墙检测 if (nx 0 || nx MAP_WIDTH || ny 0 || ny MAP_HEIGHT) { state STATE_OVER; drawOver(); return; } bool eat (nx foodX ny foodY); // 自碰检测 if (hitSelf(snake, nx, ny, eat)) { state STATE_OVER; drawOver(); return; } // 记录旧尾巴坐标用于擦除 int oldTailX snake.x[snake.tail]; int oldTailY snake.y[snake.tail]; // 移动蛇身 moveSnake(snake, nx, ny, eat); // 绘制新蛇头 gotoxy(nx, ny); SetConsoleTextAttribute(handle, 10); std::cout ■; if (eat) { score 10; updateScoreBoard(); // 食物被吃了重新生成 spawnFood(); drawFood(); // 难度递进 if (score % 50 0 interval 60) { interval - 10; } } else { // 没吃到食物擦除尾巴 gotoxy(oldTailX, oldTailY); std::cout ; } }这段代码基本就是第二版的核心了顺序上有个关键点必须先判断碰撞再执行移动。如果把移动放在检测之前蛇头已经撞墙了你还要再倒回去处理逻辑会变得很别扭。5.3 食物生成的安全策略最简单的食物生成方式是循环随机do { foodX rand() % MAP_WIDTH; foodY rand() % MAP_HEIGHT; } while (isBody(foodX, foodY));这在小地图、蛇身短的时候没问题但当蛇身占满大半地图时随机位置很可能反复落在蛇身上碰撞检测次数急剧上升极端情况下程序会卡到看起来像死循环。更稳妥的写法是先把所有空地收集起来再从空地里随机挑一个。这里给个实现void spawnFood() { int freeCells 0; for (int y 0; y MAP_HEIGHT; y) { for (int x 0; x MAP_WIDTH; x) { if (!isBody(x, y)) freeCells; } } if (freeCells 0) { // 地图被占满游戏胜利 state STATE_OVER; return; } int target rand() % freeCells; for (int y 0; y MAP_HEIGHT; y) { for (int x 0; x MAP_WIDTH; x) { if (!isBody(x, y)) { if (target 0) { foodX x; foodY y; return; } target--; } } } }这个函数的时间复杂度是O(地图大小)即使蛇身很长也稳定。游戏快通关时这个函数能保证食物一定生成在可到达的位置。5.4 完整的绘制函数组织把绘制代码统一封装能有效避免零散输出。我通常按层次组织四个绘制函数drawBorder()只画一次放在初始化阶段之后不清除drawSnake()更新蛇时调用画整个蛇身适合初始化以及重置后重绘drawFood()画当前位置的食物drawUI()在右侧画分数、难度、操作提示。初始化时调用一次全部绘制之后每帧只做局部更新这样代码清晰也不会因为忘画某个元素导致画面残缺。6. 常见问题与调试心得6.1 蛇头画出来了但身体断节或者重叠这个问题十有八九出在环形数组的取模和head/tail更新顺序上。记住一个口诀先写新头到(head1)%MAX_BODY再移动head游标最后才根据是否吃到食物去动tail。如果先动了tail或者head下一次绘制时遍历的起点就会错乱。排查时可以打印head、tail和len三个值蛇动几帧后再对比数组里每个坐标很快就能定位是哪个环节出了问题。6.2 方向键没反应或者按一次走两步方向键没反应优先检查有没有处理224/0这个前导字节。只读一次_getch()拿到的根本不是一个有效方向键。按一次走两步多半是按键的“重复触发”。如果你在循环里每次都调用readDirection()键盘按住时系统会连续产生重复按键事件。解决方法是维护一个“本帧是否已处理过方向”的标志每帧只允许修改一次方向或者干脆用“方向缓冲”只在方向键第一次生效。6.3 速度忽快忽慢前面提过别用Sleep做速度控制要用时间戳判断。如果你已经用了时间戳还是忽快忽慢注意检查render()里有没有执行耗时大的操作比如每次渲染都遍历整个地图或者调用SetConsoleTextAttribute太多。优化思路是把颜色设置也缓存下来只在变化时才调用。6.4 蛇吃到自己的身体竟然没死最可能的原因是自碰检测时忘了考虑“尾巴即将移走”的情况。当蛇没有吃到食物时新的蛇头可以合法地移动到当前尾巴的位置因为那一帧尾巴会立刻移开。如果检测函数把尾巴计入身体游戏会在本该活着的时候判死。反过来如果吃到食物时尾巴不移走你确实应该判死。所以检测函数的第三个参数grow必须精确传达“这一帧蛇是否增长”的状态两个分支处理方式不同。6.5 我给新手的排查技巧单步调试模式调试游戏逻辑比调试普通程序难点在于画面一直在变。我的做法是加一个隐藏的“单步模式”按P键进入暂停按N键手动执行一次update然后打印出蛇头坐标、方向、长度和下一帧新头位置。if (key n state STATE_PAUSE) { updateGame(); drawGame(); }这个功能在排查“为什么这里撞了”“为什么那里没撞”时极其有效你可以一帧一帧地观察蛇的状态变化。等所有逻辑都调通了再把这个调试分支注释掉。在我看来控制台贪吃蛇这个项目写到这里已经不是“能不能跑”的问题而是“好不好玩”的问题。环形数组把蛇身管理变得稳定定点重绘消灭了闪烁固定时间戳让加速减速都变得可控状态机又给了你扩展菜单和暂停的底气。这一套思维不止适用于贪吃蛇你随便拿一个控制台小游戏套上“状态机定时更新局部重绘”这个框架手感都会比瞎写一气的版本强很多。后面如果再想加东西最高分存档、蛇的AI自动寻路、双人模式都只是在这个骨架上面继续长肌肉了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑