1. 先把“控制台简易程序”这几个字拆开看
我见过太多人把控制台贪吃蛇当成入门练手的一次性作业:能跑起来、能吃到食物、撞墙会死,然后代码一关就再也没打开过。但只要你在终端里认真跑一遍,会发现这个“控制台简易程序”远没有那么简易——它同时踩在四个领域的交叉点上:终端交互控制、实时输入处理、状态机设计、以及最容易被忽略的手感调优。在这几个方向上,它和任何一个带界面的实时程序没有本质区别,只是把渲染后端换成了字符流。
贪吃蛇真正难的地方不在算法。蛇的移动逻辑用二十行就能写完,但要让它在终端里跑得“像那么回事”——不闪屏、按键不丢、连按不反向自杀、尾巴格子能正确跟进——这些细节才是把玩具和作品区分开的东西。我在不同机器上重写过好几遍这个东西,每次踩的坑都不一样:有的坑来自终端缓冲,有的坑来自字符宽高比,有的坑来自我自己写的帧率控制把 CPU 吃满了。
这篇内容面向三类人:刚学完数组和循环、想找个能立刻看到反馈的项目练手的初学者;写过贪吃蛇但做出来一卡一卡、手感很差,想知道差在哪里的进阶者;以及想拿一个纯字符界面的小项目去理解“渲染循环—输入循环—状态更新”这套通用结构的人。我会把选型理由、坐标系约定、缓存结构、输入队列、渲染策略、速度曲线一层层拆开,代码给到能直接编译的程度,踩过的坑单独拎一章讲透。
我选的是 C 语言实现,理由是它最接近终端的原始面貌:没有框架帮你兜底,你能清楚看到每一个字节是怎么出去的。但这套设计思路换成 Python、Rust、Go 都成立,第 1.2 节我会给出横向对比表,你可以按自己手上的语言挑一条路走。
2. 整体设计与核心技术点拆解
2.1 为什么主循环一定要做成“固定步长”
新手写的第一版贪吃蛇,常见结构是在while里先等输入、再移动、再清屏重绘。这个顺序在小尺寸下看起来没问题,一旦速度调快就开始出问题:如果等待输入是阻塞的,那么玩家不按键游戏就静止;如果不等输入直接跑,那么蛇的移动速度取决于你的机器有多快,同一份代码在不同机器上快慢差好几倍。
正确的结构是固定步长的主循环:每一帧算出目标时长,比如 130 毫秒,处理完输入和逻辑之后补齐剩余时间再进入下一帧。这样蛇的移动速度由你定义的帧时长决定,与机器性能解耦。我用的骨架大致是这样:
while (!g_over) { long long t0 = now_ms(); /* 1. 把这一帧内累积的按键全部读干净 */ while (kb_hit()) { int ch = kb_get(); push_dir(ch); } /* 2. 状态推进:移动、判碰撞、吃食物 */ tick(); /* 3. 只重绘发生变化的格子 */ render_diff(); /* 4. 补齐帧时间,避免空转烧 CPU */ long long cost = now_ms() - t0; if (cost < g_frame_ms) sleep_ms((int)(g_frame_ms - cost)); }这里有个很关键的决定:输入的读取频率和逻辑推进频率必须解耦。按键检测放在每一帧的开头,用非阻塞的方式把缓冲区里的按键全部取出来,而不是“取一个就走”。原因很直白——人手快速连按两次方向键的间隔可能只有三四十毫秒,如果一帧只处理一个按键,另一次按键就会丢失,表现出来就是“明明按了上,蛇却直走撞墙”。这个现象我在早期版本里遇到过很多次,最后归结为输入缓冲区没有读干净。
另一个决定是逻辑与渲染分离。贪吃蛇的逻辑一帧只推进一格,但渲染如果每帧全量重画,终端会闪得人眼睛疼。所以我把状态更新和画面更新拆成两个函数,渲染函数只负责把“这一帧和上一帧不同的格子”刷一遍,大部分时候只需要动三个位置:新的蛇头、旧的蛇尾、以及被吃掉的食物。这个优化做不做,观感差别巨大。
2.2 蛇身用环形队列,不要用链表
蛇身的数据结构是这个项目里最值得推敲的一处。初学者常写malloc一个节点做链表,头插尾删,逻辑上很自然,但实际写下来问题不少:每次移动都要分配和释放一次,容易写出内存泄漏;缓存局部性极差,遍历判断自撞时性能全靠运气;而且一旦要判断“新蛇头是否会撞到自己”,还得从头遍历,代码读起来啰嗦。
我用的是定长数组加头尾索引的环形队列。棋盘最多W*H个格子,蛇最长不可能超过这个数,所以直接开一个Point body[W*H],用head和tail两个下标管理。移动一格的语义变成:head = (head + 1) % CAP,把新坐标写进body[head];如果这一格没有吃到食物,tail = (tail + 1) % CAP;吃到食物时尾巴不动,长度自然加一。
#define W 24 #define H 18 #define CAP (W * H) static Point body[CAP]; static int head, tail, len; /* 推进一格;返回 1 表示吃到食物 */ static int advance(Point nh, int eat) { head = (head + 1) % CAP; body[head] = nh; if (eat) { len++; return 1; } tail = (tail + 1) % CAP; /* 尾巴跟进,等价于删除尾节点 */ return 0; }这个写法的好处很实在:没有一次动态分配,缓存友好,长度就是len,胜负判断只需要一句len == CAP。代价是你必须严格保证队列容量够用,所以我把CAP直接设成格子总数,宁可用不满,也不要出现绕着绕着把自己覆盖掉的情况。
注意:环形队列最容易写错的是“满”和“空”的判定。当
len == CAP时头尾索引会重合,此时按“蛇占满棋盘”直接判胜利并退出,就能绕开这个经典歧义。
2.3 坐标系怎么约定,决定了后面一半的 bug
坐标这件事看起来最简单,实际上是最容易埋雷的地方。我给自己定死两条规矩:游戏坐标从 0 开始,x 表示列、y 表示行;终端坐标从 1 开始,格式是“行在前、列在后”。两者之间只在一个地方做转换,那就是定位输出函数:
/* 把游戏坐标 (x, y) 转成终端光标位置 */ static void goto_cell(int x, int y) { printf("\x1b[%d;%dH", BOARD_ROW + y, BOARD_COL + x * CELL_W); }BOARD_ROW和BOARD_COL是棋盘左上角在终端里的绝对位置,CELL_W是每个格子占用的字符宽度。把转换集中在一个函数里,后面所有绘制、调试、边界检查都调它,就不会出现“这里 +1 那里没 +1”的连锁错误。我在早期版本里就是因为有的地方手写printf("\x1b[%d;%dH")忘了偏移,导致食物出现的位置和实际判定位置差了一行,玩起来像见鬼。
还有一个几乎所有人都会忽略的细节:终端字符不是正方形。常规等宽字体里,一个字符的高大约是宽的两倍。如果横竖都用一格,画出来的蛇在横向上会被压扁,整个棋盘看起来是长方形的。解决办法是每个格子在横向上占两个字符宽度,用一个空格加一个色块符号,或者干脆两个相同符号,视觉上就接近正方形了。
2.4 技术选型:C、Python 还是别的语言
我在不同阶段用过几种语言写同一个东西,感受差别挺大,整理成表给你参考:
| 方案 | 终端控制方式 | 输入处理 | 上手成本 | 适合谁 |
|---|---|---|---|---|
| C + ANSI 转义序列 | 手写\x1b[...]定位与清屏 | Windows 用_kbhit,类 Unix 用termios关闭行缓冲 | 中,需要理解终端控制码 | 想搞明白底层交互的人 |
| C + 控制台 API | SetConsoleCursorPosition等 | GetAsyncKeyState或_kbhit | 中,代码不跨平台 | 只面向特定桌面环境 |
| Python + curses | 库封装好的窗口与光标 | getch配合nodelay | 低,逻辑写得飞快 | 想专注游戏逻辑的人 |
| Python + 转义序列 | 手写字符串输出 | select加tty.setcbreak | 低 | 快速验证手感 |
| Rust/Go + 终端库 | 第三方库封装 | 事件流 | 中高 | 想顺带练语言的人 |
选 C 的理由是它逼着我把“终端到底是什么”搞清楚。你现在看到的这行字、这个光标、这个滚动区域,本质上都是一串控制字符在起作用,理解了这一层,后面换任何语言都只是换语法。选 Python 的理由也很充分:curses 把按键、刷新、窗口尺寸都封装好了,你可以在二十行内看到一个能动的蛇,把精力全放在手感和规则设计上。
2.5 状态机的整体拆解
把整个游戏拆成状态,思路会清楚很多。我划分了四个状态:初始化(建棋盘、放蛇、放食物)、运行(主循环推进)、暂停(保留画面、不推进逻辑)、结束(显示成绩、等待退出)。四个状态之间的迁移条件很少,写成一个switch就够:
enum State { ST_INIT, ST_RUN, ST_PAUSE, ST_OVER }; static void tick(void) { Point nh = body[head]; nh.x += DX[g_dir]; nh.y += DY[g_dir]; if (nh.x < 0 || nh.x >= W || nh.y < 0 || nh.y >= H) { g_state = ST_OVER; /* 撞墙 */ return; } int eating = (nh.x == g_food.x && nh.y == g_food.y); if (hit_self(nh, eating)) { g_state = ST_OVER; /* 撞自己 */ return; } advance(nh, eating); if (eating) { g_score++; spawn_food(); speed_up(); } }状态机的好处在于,每个状态的职责是单一的。暂停时我只画一行提示,逻辑一行不跑;结束时我只读一个键,不再处理方向输入。如果没有这层划分,代码里会充斥着if (paused && !over && started)这种互相纠缠的判断,改一处崩三处。
3. 控制台交互里那些“文档不会写”的细节
3.1 光标定位与局部刷新,闪屏的根源在这里
终端渲染的核心只有一句话:光标移过去,然后覆写。所有花哨的界面效果都是这句话的排列组合。清屏用\x1b[2J,光标归位用\x1b[H,隐藏光标用\x1b[?25l,恢复用\x1b[?25h。这几个序列记牢,剩下的是拼装。
闪屏问题几乎全部来自“全量重绘”。如果你每一帧都先清屏再重画整个棋盘,屏幕会出现明显的白闪,速度越快闪得越厉害。原因很简单:清屏和重画之间有一段时间差,人眼能捕捉到。解决办法是只重绘变化的部分。蛇移动一格,最多影响三个格子的显示:新蛇头位置画上蛇身、原蛇尾位置画成空白、如果吃到食物就把食物位置改画成蛇头。
static void render_diff(void) { /* 新蛇头 */ goto_cell(body[head].x, body[head].y); fputs("\x1b[32m[]\x1b[0m", stdout); /* 旧蛇尾清空(没吃到食物时尾巴才会前进) */ if (!g_ate) { Point t = body[tail]; goto_cell(t.x, t.y); fputs(" ", stdout); } /* 食物被吃掉后在新位置重画 */ if (g_ate) { draw_food(); } fflush(stdout); }这里有两个必须记住的操作习惯。第一,每次输出后要fflush(stdout)。终端的行缓冲机制会把你的字符攒着,攒够了或者遇到换行才真正吐出去,导致画面延迟甚至整帧丢失。第二,光标定位序列是“行;列”而不是“列;行”,搞反了画面会变成镜像的,很难一眼看出来哪里错了。
注意:
\x1b[?25l隐藏光标之后,一定要在程序退出路径上写\x1b[?25h。如果程序中途崩溃或者被强制结束,终端会一直处于无光标状态,用户会以为终端坏了。稳妥做法是用atexit注册一个恢复函数,把光标恢复、颜色重置、清屏这三件事一起做掉。
3.2 非阻塞输入:为什么scanf一定会毁掉你的游戏
标准输入默认是行缓冲加回显的模式:你敲一个字符,它先被终端缓存,等你按回车,才一次性交给程序,同时这个字符还会显示在屏幕上。这套机制为命令行工具设计得很合理,但放在实时游戏里是灾难——蛇会等你按回车才动,而且方向键那些字符会直接印在棋盘上。
要做实时输入,必须关掉这两个特性。在 Windows 上简单,_kbhit()查有没有按键,_getch()取一个字符且不回显。在类 Unix 系统上需要操作termios:
#ifndef _WIN32 #include <termios.h> #include <unistd.h> static struct termios g_saved; static void term_init(void) { tcgetattr(STDIN_FILENO, &g_saved); struct termios t = g_saved; t.c_lflag &= ~(ICANON | ECHO); /* 关行缓冲、关回显 */ t.c_cc[VMIN] = 0; /* 非阻塞读 */ t.c_cc[VTIME] = 0; tcsetattr(STDIN_FILENO, TCSANOW, &t); } static void term_restore(void) { tcsetattr(STDIN_FILENO, TCSANOW, &g_saved); } #endif这里我踩过一个很有代表性的坑:最早我把tcsetattr写在kb_hit()里面,每次检测按键都切换一次终端模式。功能上是对的,但速度调快之后明显感觉游戏变卡,因为每帧几十次系统调用把时间全吃掉了。后来改成初始化时设置一次、退出时还原一次,性能立刻正常。这个教训很通用:任何“每次调用都做一次全局状态切换”的代码,只要落在热路径上,早晚会咬你一口。
3.3 方向输入要排队,不能直接覆盖
玩家的手速是可以超过一帧的。假设当前帧时长 120 毫秒,一个熟练玩家在 100 毫秒内连按“上、右”,如果你只保存最后一次的按键,蛇会直接向右走,玩家的“上”被吞了。更糟的情况是当前向右走时连按“上、左”:update时读到“左”判定为反向、忽略,然后玩家发现自己的操作完全没反应。
我的做法是方向缓冲队列,最多缓存两个待执行方向,每次逻辑推进时取出一个。压入时做两项检查:新方向不能与队列末尾的方向相反,也不能与当前方向相反。
#define QCAP 2 static int q_dir[QCAP]; static int q_head, q_tail, q_len; static int is_opposite(int a, int b) { return DX[a] + DX[b] == 0 && DY[a] + DY[b] == 0; } static void push_dir(int d) { if (q_len == 0) { if (is_opposite(d, g_dir)) return; } else if (q_len >= QCAP) { return; } else { if (is_opposite(d, q_dir[(q_head + q_len - 1) % QCAP])) return; } q_dir[(q_head + q_len) % QCAP] = d; q_len++; } static void pop_dir(void) { if (q_len == 0) return; g_dir = q_dir[q_head]; q_head = (q_head + 1) % QCAP; q_len--; }这个结构看似小题大做,但它直接决定了操作的“跟手程度”。加完之后我实测,快速做出一个 U 形转弯的成功率从明显丢键变成了基本百分之百,尤其在小尺寸、高速情况下差别最明显。队列容量我设成 2,设大了反而会让操作有种延迟感——玩家早就改主意了,蛇还在执行两个身位之前的指令。
3.4 输入的方向键编码陷阱
方向键在终端里不是一个字符,而是一串。小键盘的方向键通常发出ESC [ A这样的三字节序列,有的终端发ESC O A。如果你只读一个字符,读到的是 ESC,剩下的[A会被当成两个独立按键,落在你的方向映射表里就是无效输入甚至乱跳。
应对办法有两条:一是只在 Windows 下用_getch()读两次(方向键第一次返回 224 或 0),二是干脆支持 WASD 和 hjkl 这两套字母键作为主操作,方向键作为补充。我自己的版本里主操作就是 WASD,稳定、不依赖终端差异,跨平台零成本。方向键我在读取时做了一次“吞掉后续字节”的清理:读到 ESC 就直接丢掉接下来的一到两个字节,防止污染。
4. 完整实操:从零到能玩的一版
4.1 环境准备与工程骨架
需要的东西极少:一个 C 编译器、一个支持的终端、几十行启动代码。Linux 下用gcc snake.c -o snake -Wall -Wextra,Windows 下用任意 C 编译器都行,注意加上#ifdef _WIN32的移植层。
文件结构我习惯拆成三块放一个文件里:平台抽象层(时间、睡眠、按键)、游戏逻辑层(状态、推进、碰撞)、渲染层(定位、绘制、颜色)。单文件是为了方便复制,但三层边界在代码里用注释明确标出来,后面想加功能不会找不到地方。
平台层要提供四个函数:now_ms()取毫秒时间戳、sleep_ms()睡眠、kb_hit()查按键、kb_get()读一个按键。这四个凑齐,上层逻辑一行平台相关的代码都不用写。
#ifdef _WIN32 #include <conio.h> #include <windows.h> #define kb_hit() _kbhit() #define kb_get() _getch() #define sleep_ms(ms) Sleep(ms) #else /* termios 版本见 3.2 节 */ #endif4.2 初始化:蛇、食物和画布
初始化要做四件事:画外框、放蛇、放食物、进入运行态。蛇的初始长度我定成 3,位置放在棋盘正中横向排开,方向朝右。为什么不放长一点?因为开局太长的蛇在小棋盘上转身困难,第一口食物可能就吃不到了,体验很差。
static void init_game(void) { int cx = W / 2, cy = H / 2; head = 2; tail = 0; len = 3; body[0] = (Point){cx - 2, cy}; body[1] = (Point){cx - 1, cy}; body[2] = (Point){cx, cy}; g_dir = DIR_R; g_score = 0; g_frame_ms = 150; spawn_food(); g_state = ST_RUN; }4.3 食物生成:绕开“随机重试”的死循环
新手写食物生成,最常见的是“随机一个坐标,如果落在蛇身上就重新随机”。这个写法在棋盘空的时候没问题,但当蛇长到占了棋盘八成以上,一次成功的概率只剩两成,平均要试五次;再往后会频繁重试几十次,表现为明显的卡顿,极端情况下蛇占满棋盘就成了死循环。
我用的办法是先数空格,再随机挑第 k 个空格。只需要遍历一遍棋盘,把非蛇身的格子收集起来,然后在总数里随机取一个下标。这个做法的时间复杂度稳定在 O(W*H),和蛇的长度无关,永远不会卡。
static void spawn_food(void) { int total = W * H - len; if (total <= 0) { g_state = ST_OVER; return; } /* 满盘,通关 */ int pick = rand() % total; for (int y = 0; y < H; y++) { for (int x = 0; x < W; x++) { if (occupied(x, y)) continue; if (pick-- == 0) { g_food = (Point){x, y}; return; } } } }顺带说一句rand()的种子。不调srand的话每次开局食物位置完全一样,玩家第二次玩就能背板。种子用time(NULL)就够了,别用固定值。
4.4 碰撞检测里那个容易写错的分支
自撞检测的直觉写法是“新蛇头坐标是否出现在蛇身数组里”。但这个判断有一个致命的边界情况:当这一步不吃食物时,蛇尾会在同一帧移走,新蛇头进入原来的尾格是合法的。如果直接遍历整个蛇身数组,这个合法动作会被判成死亡,玩家会觉得“我明明没撞到,为什么死了”。
static int hit_self(Point nh, int eating) { /* 遍历有效区间;不吃食物时最后一格(旧尾巴)要排除 */ int cnt = len - (eating ? 0 : 1); for (int i = 0, idx = tail; i < cnt; i++) { if (body[idx].x == nh.x && body[idx].y == nh.y) return 1; idx = (idx + 1) % CAP; } return 0; }我最早就是漏了这个分支,测试的时候让蛇绕一个小圈贴着自己走,结果刚贴上去就死了,排查了半天才发现是“尾巴算进去了”。这个 bug 很隐蔽,因为大部分时间蛇身中间很长,不贴尾走根本触发不到。如果你要测它,方法很简单:让蛇贴着身体绕一个 4x4 的小方框,能绕完说明判断正确。
4.5 帧率控制与速度曲线
帧时长我一开始写死 120 毫秒,实测下来有两个问题:开局太慢显得拖沓,后期又不够紧张。后来改成随分数递减:
static void speed_up(void) { if (g_score % 5 == 0 && g_frame_ms > 65) { g_frame_ms -= 8; } }每吃五个食物减八毫秒,下限 65 毫秒。为什么下限是 65?这是我实际试出来的数字:低于 65 毫秒时,一格移动的时间比人眼反应时间还短,玩家在高速段基本靠预判,操作体验从“刺激”变成了“失控”。这个值跟终端刷新、终端模拟器性能都有关系,你在自己的环境里可以再微调,但别低于 60。
还有一点必须提醒:帧时长不能靠空循环来“等待”。有人写while (now - t0 < frame_ms);来磨时间,这个空转会把一个核的利用率吃到百分之百,笔记本风扇立刻起飞。老老实实调用系统睡眠函数。
4.6 收尾处理:退出时把终端还回去
程序结束时必须做三件事:恢复光标显示、重置颜色、把光标移到屏幕下方干净的位置。少了这些,终端会保持着你用过的颜色和隐藏光标的状态,后面在这个窗口里跑任何命令都别扭。
static void cleanup(void) { fputs("\x1b[0m\x1b[?25h", stdout); /* 重置颜色、显示光标 */ fflush(stdout); #ifndef _WIN32 term_restore(); #endif }在main里用atexit(cleanup)注册,这样即使中途走了其他退出路径也能兜住。至于被强制结束的情况就没办法了,只能靠用户手动敲reset恢复。
5. 常见问题与排查技巧实录
5.1 画面闪烁、拖影、字符错位
闪烁基本可以确定是全量清屏造成的。判断方法很简单:把渲染函数注释掉清屏那一句,只保留定位绘制,如果闪没了,问题就确认。改成局部刷新之后,闪烁几乎立刻消失。如果改了还存在轻微闪动,看一下是不是每帧都在fflush之后又拼了一整屏字符串,输出量太大一样会闪。
拖影的原因正好相反:你只画了新的,没擦旧的。贪吃蛇每帧必须擦掉尾巴,否则蛇身会越来越长、一片糊。如果你发现蛇看起来像个实心色块,八成是尾巴没清。
字符错位通常有两个来源。一是坐标转换的偏移不一致,某个函数忘了加棋盘偏移;二是中文字符宽度问题——千万不要在棋盘区用中文或者全角符号做绘制,全角字符占两个字符宽,会把整个对齐体系打乱,棋盘立刻歪掉。我用的绘制符号全是 ASCII 的[]、##、@@,安全可靠。
5.2 方向键没反应,或者按一次动两格
“按一次动两格”是输入缓冲区里积攒了多余按键的典型症状。原因是你一帧只取一个字符,但玩家按一下键,终端可能往缓冲区里塞了不止一个字节(方向键的三字节序列就是典型)。解决办法在 3.4 节说过:读到 ESC 就把后续字节吞掉,或者干脆把主操作换成一字节的字母键。
“没反应”则要看是不是被反向判定吃掉了。玩家向左走时按右,方向被忽略是正确行为,但如果你在队列里没有做“与末尾方向比较”而只比较了当前方向,快速连按会把自己的有效指令误杀。调试方法是在每次按键时打一行日志到临时文件(注意别打到屏幕上,会破坏画面),玩几秒钟看日志里实际收到了什么。
5.3 食物出现在蛇身上,或者吃不到
食物画在蛇身上,统计上只有两种可能:要么生成函数没排除蛇身,要么你画食物和画蛇的顺序反了,蛇盖住了食物。前者用 4.3 节的计数法可以直接根治,后者检查一下绘制顺序,食物应该在蛇之后画。
“看着撞上了却没吃到”是坐标不一致导致的,一定有个地方的 x/y 或者行列搞反了。最快的排查手段是打开一个调试开关,把蛇头坐标和食物坐标同时打印到屏幕角落,玩一局就能看出是哪边差了一位。
5.4 一按方向键蛇就死了
如果蛇的长度大于 1,且你按的是当前方向的反方向,正确的行为是忽略。但如果你的反向判定写成了“只要不在四个方向里就放行”,或者 DX/DY 数组的顺序和方向枚举对不上,就会出现按一下直接掉头撞自己脖子。检查方法很直接:把反向判定那段单独拿出来,手工列出四对相反方向验证一遍。我用的判定是DX[a] + DX[b] == 0 && DY[a] + DY[b] == 0,这个写法对水平、垂直、以及未来可能加入的斜向扩展都成立,比硬编码四组判断可靠。
5.5 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 整屏白闪 | 每帧全量清屏重绘 | 改局部刷新,只动变化格子 |
| 蛇身糊成一片 | 尾巴没擦除 | 移动前记录旧尾坐标并擦掉 |
| 按一下动两格 | 输入缓冲区积压 | 每帧把按键读干净,吞掉 ESC 后续字节 |
| 快速连按丢键 | 方向只存一个值 | 改成容量为 2 的方向队列 |
| 贴着自己走会死 | 自撞检测把旧尾算进去 | 不吃食物时排除尾格 |
| 后期卡顿 | 食物生成的随机重试 | 改为按空格下标采样 |
| CPU 占用高 | 用空循环磨时间 | 换系统睡眠函数 |
| 退出后光标消失 | 没写恢复序列 | 用atexit注册清理函数 |
| 棋盘看起来是扁的 | 字符宽高比约 1:2 | 每格横向占两个字符宽 |
| 每次开局食物位置一样 | 没设随机种子 | 用时间戳初始化 |
5.6 两个从实战里攒下来的小心得
第一个心得和调试有关:给自己留一个“录像模式”。做法是把每帧的方向、蛇头坐标、长度写到一个日志文件里,游戏结束后能回放。听起来有点重,但当你遇到偶发的“莫名其妙死了”时,没有日志你只能靠猜,有日志可以精确还原到那一帧。我靠这个办法抓到了两个只在高速下才复现的边界 bug。
第二个心得和测试有关:不要只测正常路径。贪吃蛇最容易出错的都是边缘情况——蛇贴墙走、蛇贴着自己的尾巴转圈、蛇长到接近满盘、最后一口食物。我会专门写一个“填满棋盘”的测试模式,把蛇直接初始化成很长的一条,然后手动走位去触发这些边界。省下来的时间远超过写测试的时间。
6. 手感调优与后续扩展
6.1 手感调优:影响体验的不只是速度
很多人以为调手感就是调快慢,其实至少有三个维度。第一个是初速。开局太快玩家来不及适应,太慢又显得拖沓,我实测 150 毫秒起步比较舒服,玩家有个一两秒的适应期。第二个是增速节奏。每五个食物减八毫秒的曲线比较平缓,不会出现突然变难的感觉;如果每吃一个就减,两三个食物之后就没法玩了。第三个是输入延迟。这个最容易被忽略:从按键被读到、到蛇真的动,中间隔了多久。
输入延迟的优化空间比想象中大。如果你的循环是“先睡眠再检测输入”,那么玩家按键之后最长要等一整帧才会被响应,操作会明显发飘。改成“先进循环立刻检测输入、再补睡”,延迟就降到了接近零。这两个版本的帧时长完全一样,但手感差别很明显。我在对比测试里让同一个玩家分别玩两个版本,一致反馈后者“跟手得多”。
还有一个纯视觉上的小技巧:蛇头用和身体不同的符号或颜色。人是靠头部位置的视觉变化来判断方块的转向的,如果整条蛇颜色一样,快速转向时眼睛会跟丢。我用的是绿色方块做身体、亮色标记做头,辨识度立刻提升。
6.2 可以继续扩展的方向
基础的版本跑通之后,这个骨架能撑起很多扩展,而且每个扩展都是一次不错的技术练习。
穿墙模式。把越界判定从“死亡”改成“坐标取模”,一行代码的事,但游戏性质完全变了,从紧张变成放松,适合做双模式切换。注意取模时负数要修正,(x + W) % W才是安全的。
障碍物与关卡。在棋盘上预置一些固定格子作为墙,碰撞判定里加一条。这会顺带改变食物生成的空格统计,需要一并更新occupied函数。做成关卡数据文件之后,就是一个小型关卡系统。
双人对战。两个人各控制一条蛇,同一套输入队列,只是方向来源不同。难度在于同一帧里两条蛇同时移动可能撞上同一个格子、或者互换位置(头碰头),这些都需要在推进之前先把两条蛇的下一格都算出来,再统一判碰撞,不然会出现“谁先谁赢”的不公平。
成绩记录与回放。把每局的方向序列和时间戳存下来,就能实现回放。这个功能做起来不复杂,但对理解“状态可以被序列化”这件事很有帮助。
渲染升级。从纯字符换到带背景色的方块,视觉档次立刻不一样。再进一步可以用半角块字符拼出更细腻的形状,但要注意别用太多颜色,终端配色是用户自己的,撞色会很难看。
我自己的习惯是每加一个功能就顺手加一个开关,用命令行参数控制,这样同一个程序能同时当练习场和正式版本用。控制台程序最容易变得不可维护的原因是配置硬编码在代码里,一旦要改就得重编译,几次之后就懒得动了。
最后一个小提醒:如果你打算把这个东西分享给别人玩,最值得花时间打磨的不是功能数量,而是退出路径的健壮性和窗口尺寸检查。终端宽度不够时棋盘会折行,整个画面直接错乱——在渲染之前查一次终端尺寸,不够就提示用户,这个几十行的检查比多写三个游戏模式有用得多。