简介基于C与OpenGL开发的贪吃蛇游戏课程设计完整项目包面向计算机专业学生、OpenGL初学者以及需要完成游戏类课程设计的开发者。项目采用OpenGL3、GLFW与GLAD构建覆盖游戏框架搭建、3D精灵加载与渲染、WASD控制、边界穿越以及障碍物与自身碰撞检测等核心要求提供可直接运行和二次扩展的基础。资源包共62个文件其中以C源码和相关头文件为主同时包含Visual Studio工程文件、编译中间产物与可执行程序、运行效果示意图及课程设计报告等压缩包大小52.42MB。目前已有371人学习下载。结合源码、文档与演示素材读者可以快速理清贪吃蛇游戏从窗口初始化、渲染循环到键盘交互、蛇身生长与碰撞判断的完整开发脉络掌握三维模型显示、用户交互和碰撞响应等关键技能为自主完成课设或进一步增加计分、关卡等玩法提供扎实基础。1. 用 C 和 OpenGL 写贪吃蛇真正的难点不在“画蛇”我第一次用 OpenGL 写贪吃蛇的时候以为工程量的重心在怎么把蛇画出来。等到亲手搭完才发现OpenGL 在这个小游戏里只负责“把棋盘填上颜色”真正决定游戏好不好玩的是 C 侧的数据结构、输入处理和游戏循环。窗口怎么建、坐标系怎么映射、方向键怎么防呆、定时器放在哪个回调里这些才是大多数教程讲不透的部分。这个题材适合两类人一类是刚学完 C 基础、想第一次接触图形编程的学生另一类是工作多年但没碰过 OpenGL 的工程师想用最小成本验证自己对图形管线的理解。下文按我实际调通这个项目的顺序来写从环境搭建一路走到窗口自适应缩放的进阶技巧每一步都给可复现的代码和参数说明。2. OpenGL 环境配置选对窗口库比选 OpenGL 版本更影响体验2.1 为什么建议用 freeglut而不是直接裸调 GLFW 或 GLUTOpenGL 本身只规定渲染状态机和绘图指令不管“创建窗口”“接收键盘事件”这些事。你必须借助一个窗口系统接口或工具库把这些能力接进来。常见选择有三个原版 GLUT、freeglut、GLFW。原版 GLUT 已经停止维护GLFW 功能全但 API 偏现代回调模型和 GLUT 接近初始化选项更多freeglut 是 GLUT 的开源替代品接口保持经典回调风格在 Windows 和 Linux 上都能用一条命令装好。我的习惯是用 freeglut理由很简单贪吃蛇需要的窗口、键盘、定时器三个能力freeglut 每个都给你对应的回调函数没有多余的概念学习成本最低。如果你以后要写更复杂的交互程序再迁移到 GLFW 也不迟游戏逻辑部分和渲染部分都能原样搬走。窗口库维护状态键盘回调风格定时器支持适合场景GLUT原版基本停止glutKeyboardFunc有老课程项目freeglut活跃glutKeyboardFunc / glutSpecialFuncglutTimerFunc教学、小游戏GLFW活跃glfwSetKeyCallbackglfwSetTimer需自行封装现代项目、编辑器从表中可以看出freeglut 把“方向键”和“普通字符键”分开处理这对贪吃蛇尤其合适因为方向键对应glutSpecialFuncR 键重启对应glutKeyboardFunc逻辑天然分离。2.2 最小工程结构先让一个空窗口转起来无论你最终代码写多少行工程骨架都是同一套初始化 GLUT、创建窗口、注册回调、设置投影矩阵、进入主循环。我给出一个可以直接跑的最小样例它在窗口中显示一个 20x20 的黑色棋盘。#include GL/freeglut.h void display(void) { glClear(GL_COLOR_BUFFER_BIT); glutSwapBuffers(); } int main(int argc, char** argv) { glutInit(argc, argv); glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB); glutInitWindowSize(600, 600); glutInitWindowPosition(100, 100); glutCreateWindow(Snake Game with OpenGL); glutDisplayFunc(display); // 投影矩阵把游戏区域映射成 0~20 的坐标范围 glMatrixMode(GL_PROJECTION); glLoadIdentity(); glOrtho(0.0, 20.0, 0.0, 20.0, -1.0, 1.0); glMatrixMode(GL_MODELVIEW); glutMainLoop(); return 0; }代码里有几个参数值得说明。glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB)中的GLUT_DOUBLE开启双缓冲画面先绘制到后台缓冲再通过glutSwapBuffers一次交换到前台避免逐帧闪烁GLUT_RGB指定使用颜色索引模式之外的 RGB 颜色模式这是现代程序的标准选择。glOrtho(0.0, 20.0, 0.0, 20.0, -1.0, 1.0)的六个参数依次是左、右、下、上、近、远裁剪面我让坐标系原点位于左下角x 和 y 都覆盖 0 到 20正好对应 20x20 的棋盘格后面绘制蛇身时只要按整数坐标定位即可。编译命令在 Linux 上是g main.cpp -lfreeglut -lGLU -lGL -o snakeWindows 上如果你用 Visual Studio需要在 VC 目录里加上 freeglut 的 include 和 lib 路径同时保证 freeglut.dll 放在 exe 同目录。2.3 从控制台程序到窗口程序思维要换成“回调驱动”写惯了命令行程序的人第一次接触 OpenGL 会很不适应main函数里看不到while(true)游戏循环所有逻辑都被glutMainLoop()接管了。窗口绘制事件触发glutDisplayFunc注册的函数键盘事件触发glutSpecialFunc定时事件触发glutTimerFunc。这意味着你不能像控制台那样顺序执行“移动蛇、画蛇、等待”而是要在回调函数里维护一个全局状态。贪吃蛇游戏状态必须放在全局变量或静态变量中回调函数通过读写这个状态完成逻辑更新。这也是为什么很多教材把游戏状态封装成std::deque全局对象——回调模型天然适合“外部事件驱动状态变化”的程序结构。调试时你仍然可以往stdout打印日志OpenGL 窗口程序和标准输出并不冲突。3. C 侧的游戏逻辑用 deque 和状态机撑起整条蛇3.1 为什么用 std::deque 而不是 std::vector 存储蛇身贪吃蛇每一帧要做两件事在头部加入一个新坐标在尾部删除一个旧坐标。std::deque是双端队列push_front和pop_back操作都是常数时间复杂度而std::vector的头部插入是 O(n)数据量大时会卡顿。另外队列的每个元素只存一个std::pairint, int表示格子坐标内存占用极小。我封装一个Snake结构体把方向枚举和身体序列放在一起。#include deque #include utility enum class Direction { UP, DOWN, LEFT, RIGHT }; using Point std::pairint, int; struct Snake { std::dequePoint body; Direction dir; }; Snake snake; Point food; int score 0; const int GRID 20;body中第一个元素是蛇头其余依次是蛇身。dir控制移动方向。初始化时将蛇放在棋盘中央长度为 3 即可例如body {{9, 10}, {8, 10}, {7, 10}}方向默认向右。把类型定义为using Point std::pairint, int的好处是后续的碰撞检测和渲染函数都能复用这个类型不用到处写std::pairint, int的完整签名字段。3.2 移动、吃食物、碰撞检测的完整实现移动逻辑的核心是“头部加一格尾部减一格”如果吃到食物则尾部不减这样蛇就长一格。这里要注意一个新头不能与反向移动冲突比如当前方向是左按下右方向键头部 x 坐标先减一再加一相当于停在原地下一步还会撞到自己。防止这件事要在键盘回调里做但移动函数本身也要假设方向已经合法。void moveSnake() { Point head snake.body.front(); Point next head; switch (snake.dir) { case Direction::UP: next.second 1; break; case Direction::DOWN: next.second - 1; break; case Direction::LEFT: next.first - 1; break; case Direction::RIGHT: next.first 1; break; } // 撞墙判断 if (next.first 0 || next.first GRID || next.second 0 || next.second GRID) { gameOver(); return; } // 撞自己判断跳过尾巴因为尾巴马上要删除 for (size_t i 0; i snake.body.size(); i) { if (snake.body[i] next) { gameOver(); return; } } snake.body.push_front(next); if (next food) { score 10; generateFood(); } else { snake.body.pop_back(); } }这段代码里的参数和边界条件有三个细节。第一撞自己判断我遍历了整个body虽然尾巴刚好会被移除但极端情况下新头会与尾巴重合这时如果排除尾巴一帧内看不出问题下一帧尾巴消失后头部就悬空了视觉上会“穿模”。所以遍历全部位置发现重合立即结束游戏。第二generateFood()生成食物时要避开蛇身占用的格子我一般用一个for循环随机生成坐标检查是否在蛇身中若非法就重试因为 20x20 的棋盘空间充裕重试次数很少。第三gameOver的默认行为是输出分数并调用exit(0)后面在进阶章会改成“按 R 键重启”但逻辑层和执行层分离的设计让替换变得很容易。3.3 方向键回调里的防反向输入glutSpecialFunc负责接收键盘上的方向键回调签名固定为void callback(int key, int x, int y)其中key是GLUT_KEY_UP等常量x, y是鼠标位置。防反向逻辑在回调中做而不是在移动函数中做为的是在输入发生的第一时间丢弃非法方向。void specialKeys(int key, int x, int y) { switch (key) { case GLUT_KEY_UP: if (snake.dir ! Direction::DOWN) snake.dir Direction::UP; break; case GLUT_KEY_DOWN: if (snake.dir ! Direction::UP) snake.dir Direction::DOWN; break; case GLUT_KEY_LEFT: if (snake.dir ! Direction::RIGHT) snake.dir Direction::LEFT; break; case GLUT_KEY_RIGHT: if (snake.dir ! Direction::LEFT) snake.dir Direction::RIGHT; break; } }这里的关键是“当前方向不能等于相反方向”。例如蛇正向右走玩家按左这个输入必须丢弃否则下一步蛇头坐标向右加一后又被向左减一出现原地掉头的诡异现象随后极容易撞上自己的第二节身体。注意回调里只改dir不改坐标坐标更新全部放在moveSnake()中这样键盘按下速度再快也不会导致一帧内移动多次。游戏节奏由定时器控制与键盘输入频率解耦。3.4 用 glutTimerFunc 控制移动速度而不是用 glutIdleFunc很多新手会用glutIdleFunc让画面持续重绘然后在display里移动蛇。这是错误做法因为 idle 回调的调用频率取决于 CPU 和刷新率会导致蛇在不同电脑上速度不同。正确的做法是注册一个定时器回调让它在固定的毫秒间隔后触发一次移动和重绘。void timer(int value) { moveSnake(); glutPostRedisplay(); glutTimerFunc(120, timer, 0); }glutTimerFunc的第二个参数是回调函数指针第三个参数是传给回调的整数值。定时器触发一次后不会自动重复所以我在回调末尾再次调用glutTimerFunc形成循环。120 毫秒代表每两步之间的间隔也就是约 0.12 秒走一格这个速度对新手友好熟练后可以改成 80 或 60 毫秒。值越小游戏越快但注意定时器回调是在 GLUT 主循环内执行的它的间隔并不是精确实时时钟所以不要用MoveNext()之后的时间戳做插值计算后面进阶章会讲如何处理需要精确时间步进的场景。4. OpenGL 渲染从网格到一条渐变色的蛇4.1 绘制矩形方块的通用函数我们的棋盘是 20x20 的整数网格每个格子的坐标范围从(x, y)到(x1, y1)。绘制一个格子就是画一个四边形。OpenGL 的老式立即模式用glBegin(GL_QUADS)加四个顶点完成对贪吃蛇这种图元数量极少的小游戏完全够用不必引入 VBO 和着色器先把语义跑通更重要。void drawRect(int x, int y, float r, float g, float b) { glColor3f(r, g, b); glBegin(GL_QUADS); glVertex2f(x, y); glVertex2f(x 1, y); glVertex2f(x 1, y 1); glVertex2f(x, y 1); glEnd(); }这个函数接收格子坐标和 RGB 颜色。注意 OpenGL 的颜色范围是 0.0 到 1.0而不是常见的 0 到 255所以纯红色是(1.0f, 0.0f, 0.0f)深灰色是(0.15f, 0.15f, 0.15f)。绘制时顶点顺序必须是逆时针或顺时针保持一致图形学里正面和反面的区分依赖顶点顺序这里四个顶点按左下、右下、右上、左上的顺序闭合即可。glBegin和glEnd之间不要调用其他 GL 状态修改函数否则会出现绘制异常或性能问题。4.2 在 display 回调中组合出完整画面渲染回调中要按“清屏 → 画食物 → 画蛇 → 交换缓冲”的顺序执行。清屏使用glClearColor设置背景色然后调用glClear。我把食物画成红色高亮方块蛇身从尾部到头部做绿色渐变头部最亮。void display() { glClearColor(0.08f, 0.08f, 0.12f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); drawRect(food.first, food.second, 0.95f, 0.15f, 0.15f); // 蛇头画亮绿色越靠近尾部颜色越深 float total (float)snake.body.size(); for (size_t i 0; i snake.body.size(); i) { float t (float)i / total; float brightness 1.0f - 0.65f * t; drawRect(snake.body[i].first, snake.body[i].second, 0.15f, brightness, 0.15f); } glutSwapBuffers(); }先画食物再画蛇蛇头如果恰好与食物重合会被覆盖掉但这通常不会发生因为吃到食物的瞬间蛇头会向食物方向前进一格。渐变的效果是通过brightness实现的t从 0蛇头到接近 1蛇尾变化蛇头颜色亮度高蛇尾逐渐变成暗绿色。这里没有画背景网格线如果你想加分隔线可以在display开头用glColor3f(0.2f, 0.2f, 0.2f)配合glBegin(GL_LINES)画 20 条横线和 20 条竖线每帧成本极低对视觉清晰度提升明显。4.3 坐标映射为什么蛇在窗口里不会超出边界前面设置了glOrtho(0.0, 20.0, 0.0, 20.0, -1.0, 1.0)这个投影矩阵把世界坐标 0~20 映射到整个窗口。只要窗口不变形蛇跑到 x20 或 y20 时它的方块有一半在裁剪面外面视觉上像是“卡在墙里”。真正的边界判定发生在moveSnake()中即next.first GRID或next.second 0。这说明渲染层只需要画出来不需要管游戏规则游戏逻辑层只需要更新坐标不关心像素。如果你发现蛇的位置和实际方向相反检查一下glOrtho的 bottom 和 top 参数——把 (0,0) 放在左下角时y 轴向上增长按GLUT_KEY_UP对应的 y 坐标应该增加。很多教程会不小心把投影写成glOrtho(0, 20, 20, 0, ...)导致 y 轴翻转方向键表现和直觉相反。4.4 使用 OpenGL 渲染方式的几个备选方案如果你希望代码更贴近现代 OpenGL可以把glBegin替换成 VBO 加顶点数组但这需要引入 vbo、vao、着色器编译等概念代码量至少翻三倍。对贪吃蛇这种几十个方块的场景立即模式的 CPU 开销可以忽略不计完全不必为了“过时”而特意改造。真到了某个版本 OpenGL 废弃立即模式时再迁移到 VBO 也不迟核心的坐标变换、矩阵设置、双缓冲交换逻辑完全一致。另一种折中方案是用glRectf函数OpenGL 提供了绘制轴对齐矩形的便捷接口但它不支持颜色渐变和线框叠加限制比较多我一般不推荐。5. 应用技巧窗口缩放不扭曲棋盘按 R 键秒重启5.1 通过 glutReshapeFunc 保证棋盘比例不变固定glOrtho把 0~20 映射到窗口但当你拉伸窗口时方块会被压扁或拉长。解决方案是监听窗口大小变化重新计算投影矩阵以匹配窗口宽高比。注册glutReshapeFunc回调即可回调签名是void reshape(int width, int height)。void reshape(int width, int height) { if (height 0) height 1; float aspect (float)width / (float)height; float half (float)GRID / 2.0f; glViewport(0, 0, width, height); glMatrixMode(GL_PROJECTION); glLoadIdentity(); if (aspect 1.0f) { glOrtho(-half * aspect, half * aspect, -half, half, -1.0f, 1.0f); } else { glOrtho(-half, half, -half / aspect, half / aspect, -1.0f, 1.0f); } glMatrixMode(GL_MODELVIEW); }这里的关键是保证投影区域的长宽比与窗口相同。当窗口宽度大于高度时横向要扩展即左右边界乘以aspect当窗口高度大于宽度时纵向要扩展即上下边界除以aspect。这样无论窗口怎么拉格子都是正方形。注意棋盘坐标范围变成了以原点为中心由 0~20 变成了 -10~10所以绘制函数中的坐标要相应平移。最简单的方式是在display中先glTranslatef(-half, -half, 0.0f)再继续用原来的 0~20 坐标绘制这样不用改drawRect和蛇的数据结构。5.2 用 keyboard 回调实现按 R 键立即重启在specialKeys之外我们再注册普通按键回调处理 R 键和 ESC 键。重启就是把蛇的全局状态恢复为初始值同时清空得分并生成新的食物。void initGame() { snake.body.clear(); for (int i 3; i 0; --i) { snake.body.push_back({9 - i, 10}); } snake.dir Direction::RIGHT; score 0; generateFood(); } void keyboard(unsigned char key, int x, int y) { if (key r || key R) { initGame(); glutPostRedisplay(); } if (key 27) { // ESC exit(0); } }initGame中蛇头放在 (8, 10)身体向左延伸。重启后必须调用glutPostRedisplay让画面立即刷新否则屏幕会停留在死亡瞬间的最后一帧。glutPostRedisplay只是标记需要重绘实际重绘发生在主循环的下一次迭代中这个机制可以安全地在任意回调中调用不必担心递归问题。注意generateFood要避开蛇身初始占用的三个格子。5.3 验证定时器逻辑是否正确的小技巧调试这类动画程序时最常犯的错是把移动频率与帧率混为一谈。要验证定时器逻辑是否正确可以在moveSnake中打印蛇头的坐标然后观察游戏开始时蛇头 x 坐标每 120 毫秒加一方向键被拒绝时坐标变化方向不会出现 180 度反转。如果坐标变化时快时慢先检查glutTimerFunc是否在回调末尾重新注册了再检查是否存在多个定时器同时运行。还有个细节是重启后身上自带“时间残留”即上一次游戏的定时器还在链路上不过由于它注册的是同一回调重启后重新注册定时器并不会造成加速因为 GLUT 定时器是单次触发、自动续传只要 keep calling 就不会叠加。本文还有配套的精品资源点击获取