C语言控制台小游戏实战:从万年历到贪吃蛇代码详解
简介压缩包内含60套简单易懂的C语言小游戏与趣味项目源码主要面向C语言初学者、高校课程设计及毕业设计参考者。内容既涵盖24点、五子棋、俄罗斯方块、华容道、超级玛丽、坦克游戏等经典玩法也包含万年历、图书借阅/管理系统、学生成绩系统、商品销售系统等常见课设题目另有数字雨、烟花特效、ASCII图片转换、模拟cmd等趣味工具覆盖游戏循环、界面交互、文件读写与基础算法等多个编程练习维度。压缩包共591个文件大小约37.59MB源码文件以c/cpp/h为主附dsp/dsw/sln工程文件、exe可执行程序及jpg/bmp/ico图片素材、mp3/wav音频、txt/doc说明文档便于快速定位所需项目方便对照学习。目前已有6949人学习下载。整体代码结构清晰、可直接编译运行适合在课程设计或自学练手中快速提取思路、改造扩展。1. 控制台小游戏是C语言练手的最佳波段不少人在学C语言时会卡在指针和结构体上觉得语法枯燥写来写去都是图书管理系统和学生成绩单。我把这类资源在本地跑了一圈之后发现控制台小游戏反而是被低估的练手材料它不需要图形库、不依赖IDE之外的运行时环境打开终端就能出效果。这份合集里有点像样的东西比如万年历是日期计算的综合演练五子棋是二维数组和轮盘逻辑的搭配俄罗斯方块则以状态机和动态数组为骨架每一套都能在普通C语言教材的知识范围内跑起来。对刚学完语法、想看看代码怎么组合成程序的人以及准备面试、想快速过一遍常用数据结构的从业者这类源码的价值在于“看得明白、改得动”。真正跑起来之后你会发现有很多坑在教科书里根本不会提比如Windows控制台的坐标定位问题、缓冲区里残留回车导致输入被跳过、全局数组尺寸和数据合法性校验。这篇文章就按经典程度和实现复杂度分几档拆解落地的命令和代码都是可复现的。2. 先从万年历说起日期边界条件和分支逻辑2.1 为什么万年历最适合第一个上手万年历在刷题网站上很低调但它其实把条件判断、循环、数组查表、函数划分全部串起来了。一个能查任意年份、任意月份并正确输出星期排版的代码核心就两件事判断某年某月第一天是星期几以及正确处理闰年。int is_leap(int year) { return (year % 4 0 year % 100 ! 0) || (year % 400 0); } int get_month_days(int year, int month) { static int days[12] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month 2 is_leap(year)) { return 29; } return days[month - 1]; }这段代码解决的是“每个月到底有几天”这一基础问题。static int days[12]是常见的查表写法只在第一次调用时初始化之后的调用直接访问常量表比用一长串if判断简洁得多。is_leap函数把闰年的判断逻辑单独抽出之后无论是计算天数还是输出二月都只需要调用它不会出现多个地方各写一套判断导致前后不一致的问题。2.2 推算星期从已知基准日到目标日期有了每个月的天数下一步就能从公元1年1月1日开始累加算出目标日期距离基准日过了多少天再对整个周期取模。这里的优化空间是直接逐年累加太慢完全可以先算出整年的天数再按月份加天数。int get_total_days(int year, int month) { int total 0; for (int y 1; y year; y) { total is_leap(y) ? 366 : 365; } for (int m 1; m month; m) { total get_month_days(year, m); } return total; } int get_first_weekday(int year, int month) { return (get_total_days(year, month) 1) % 7; }这里get_total_days返回的是从公元1年1月1日到目标月份第一天的累计天数 1是为了让公元1年1月1日对应星期一的值。返回值范围是 0 到 6分别代表星期日到星期六日历排版时可以直接用这个结果决定第一行空出几列。注意循环边界第一个for处理的是年份从 1 到year - 1第二个for处理的是月份从 1 到month - 1目标月自身的天数不计入累计。2.3 万年历改造方向从一次查询到全年输出基础版万年历只输出指定月份改成输出全年时核心逻辑完全不用动只要在外面再套一层循环。常见做法是在循环中复用get_first_weekday和get_month_days每次输出完一个月之后打印换行分隔线。void print_year(int year) { for (int m 1; m 12; m) { printf(\n %d 年 %d 月 \n, year, m); printf(日 一 二 三 四 五 六\n); int first get_first_weekday(year, m); int days get_month_days(year, m); for (int i 0; i first; i) { printf( ); } for (int d 1; d days; d) { printf(%2d , d); if ((first d) % 7 0) { printf(\n); } } printf(\n); } }这段代码中间的printf( )是在补空格。first的范围是 0 到 60 表示星期日不需要缩进。(first d) % 7判断是否需要换行比如第一天是星期三即first等于 3那么输出 4 号时(3 4) % 7等于 0正好换行。此逻辑的精妙之处在于它同时兼容了first不为 0 的情况不用单独维护一个计数器。改完这个函数万年历就从“单月查询”升级成“全年打印”适合作为课后练习看看边界年份的处理是否正确。3. 五子棋源码拆解二维数组、轮盘逻辑和胜负判定3.1 数据结构选型棋盘数组和落子状态五子棋是二维数组应用的经典场景。别看它简单一个完整的五子棋程序至少要处理三类数据棋盘的当前状态、当前轮到哪一方、以及胜负判定结果。棋盘状态用二维字符数组或者整型数组都能表达字符版的可读性更好整型版的在判定逻辑中更省事我一般推荐用整型0 表示空位1 表示黑子2 表示白子。#define BOARD_SIZE 15 int board[BOARD_SIZE][BOARD_SIZE]; void init_board(void) { for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { board[i][j] 0; } } }二维数组的索引顺序很关键。这里board[i][j]中i表示行号j表示列号在之后的打印函数和判定函数中必须始终沿用同一个顺序。如果某个地方写成了board[j][i]画面翻转还是小事胜负判定会出现不对称的离谱结果。很多新手在行列坐标上翻车不是逻辑不对而是行列习惯不一致。3.2 规则完整性轮盘、有效性和边界检查判断一个五子棋程序是否成熟要看它有没有处理三个隐藏问题落点是否已被占据、坐标是否在棋盘范围内、落子后是否需要切换轮盘。这三件事缺一不可。int is_valid_move(int row, int col) { if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) { return 0; } if (board[row][col] ! 0) { return 0; } return 1; } void switch_player(int *current_player) { *current_player (*current_player 1) ? 2 : 1; }is_valid_move先做边界检查再做占用检查顺序不能反过来因为一旦row越界访问board[row][col]本身就是未定义行为。switch_player用指针接收当前玩家变量这样在函数内部修改的值能同步到外层调用处。如果忘记用指针而是传值进去轮盘永远不会切换游戏会一直卡在同一方下棋。3.3 胜负判定从暴力四方向扫到提前退出五子棋的胜负判定看似复杂其实只需要检查每个落子点四个方向上连续同色子的数量横向、纵向、主对角线、副对角线。每个方向各往正反两个方向延伸计数总数达到 5 就说明获胜。需要注意棋子多的时候落子后全局扫描也可以但更高效率的做法是只检查当前落点周围因为其他位置的状态并没有发生变化。int check_win(int row, int col, int player) { int directions[4][2] { {0, 1}, // 横向 {1, 0}, // 纵向 {1, 1}, // 主对角线 {1, -1} // 副对角线 }; for (int d 0; d 4; d) { int count 1; count count_direction(row, col, player, directions[d][0], directions[d][1]); count count_direction(row, col, player, -directions[d][0], -directions[d][1]); if (count 5) { return 1; } } return 0; } int count_direction(int row, int col, int player, int dr, int dc) { int count 0; int r row dr; int c col dc; while (r 0 r BOARD_SIZE c 0 c BOARD_SIZE board[r][c] player) { count; r dr; c dc; } return count; }directions数组只用四个基础方向反向由调用处传负号实现这比在数组里写八个方向要清晰还能避免重复代码。count_direction中的while循环里先检查边界再访问数组元素这一顺序绝对不能改否则读到了越界内存在 Linux 下可能崩在运行时在 Windows 下可能侥幸返回垃圾数据然后导致误判。count的初始值是 1把当前落下的这颗子先算进去。3.4 人机简单版从随机落子到启发式评分很多合集里的五子棋源码只做了双人对战如果你想把它升级成人机对战最常见的做法是做一个简易的评分函数。核心思想是遍历每个空位分别计算如果黑子落在此处和白子落在此处的分值两者相加得到该位置的最终得分AI 在合法位置里选得分最高的那个。typedef struct { int row; int col; } Move; Move ai_move(int ai_player) { int best_score -1; Move best_move {0, 0}; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (board[i][j] ! 0) { continue; } int score evaluate_position(i, j, 1) evaluate_position(i, j, 2); if (score best_score) { best_score score; best_move.row i; best_move.col j; } } } return best_move; }evaluate_position这里省略了具体实现它的任务在真实代码中是对当前空位往四个方向看统计连子数并根据连子长度加权返回分数如活四 10000、冲四 1000、活三 500、活二 100。两个玩家方向的分数相加能让 AI 同时兼顾进攻和防守不至于只盯着自己连子而漏掉堵对手。初学者可以先用rand()做随机落子跑通整体流程之后再替换成评分函数这样梯度更平滑排查问题也更方便。4. 俄罗斯方块源码深挖数据存储比渲染更重要4.1 方块形状用四维数组还是结构体加偏移俄罗斯方块中最容易误入歧途的设计决策是用一堆if-else画方块还是在数据层就把形状定义清楚。真正靠谱的做法是把七种方块统一定义然后用统一的旋转逻辑去处理而不是每个形状单独写旋转函数。#define SHAPE_COUNT 7 #define SHAPE_SIZE 4 int shapes[SHAPE_COUNT][SHAPE_SIZE][SHAPE_SIZE] { { {1, 1, 1, 1}, {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0} }, { {1, 1, 1, 0}, {0, 1, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0} }, { {1, 1, 0, 0}, {0, 1, 1, 0}, {0, 0, 0, 0}, {0, 0, 0, 0} }, { {0, 1, 1, 0}, {1, 1, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0} }, { {1, 1, 0, 0}, {1, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0} }, { {1, 1, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0} }, { {1, 1, 1, 0}, {1, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0} } };这里用 4x4 的方块矩阵来定义所有形状即shape[shape_type][y][x]。SHAPE_SIZE取 4是因为 I 形的四连块需要 4 列才能完整放下后续旋转操作在这种正方形网格中相对简单。前面几行全是 0 的原因是让形状数据在旋转时以 4x4 为基准不越界。定义成全局静态常量数组之后碰撞检测和旋转都只需要按索引访问代码结构会清爽很多。4.2 旋转算法从二维数组转置到边界恢复旋转是俄罗斯方块实现中比较有技巧性的环节。方块的旋转可以抽象为矩阵的转置加翻转分顺时针和逆时针两种情况。顺时针旋转在数学上等效于先按行做水平翻转再转置不过对玩家来说更多时候关心的是“旋转后是否与已有方块或墙体重叠”。void rotate_shape(int shape[SHAPE_SIZE][SHAPE_SIZE], int clockwise) { int temp[SHAPE_SIZE][SHAPE_SIZE]; for (int i 0; i SHAPE_SIZE; i) { for (int j 0; j SHAPE_SIZE; j) { if (clockwise) { temp[j][SHAPE_SIZE - 1 - i] shape[i][j]; } else { temp[SHAPE_SIZE - 1 - j][i] shape[i][j]; } } } for (int i 0; i SHAPE_SIZE; i) { for (int j 0; j SHAPE_SIZE; j) { shape[i][j] temp[i][j]; } } }理解这组坐标变换的关键是把原矩阵的shape[i][j]和旋转后的temp坐标对号入座。顺时针旋转时原第i行第j列的元素会跑到旋转后矩阵的第j行第SHAPE_SIZE - 1 - i列。第一次写的时候最好在草稿纸上把 4x4 矩阵的坐标演变画一遍不然下标有笔误很难查出来。旋转之后还要附加一步碰撞检测如果旋转后的位置与已有方块重叠或超出边界需要把方块移到靠墙位置这就是游戏中常见的“踢墙”设定。4.3 消行检测从逐行扫描到批量消除消行逻辑是俄罗斯方块的得分核心。最常见的做法是自底向上扫描发现某一行被填满就把它消除上方所有行整体下移一行。下移时要注意从被消除行往上遍历逐行覆盖从下往上是因为如果从上往下覆盖会覆盖掉还没处理的数据。int clear_lines(void) { int lines_cleared 0; for (int row BOARD_HEIGHT - 1; row 0; row--) { int full 1; for (int col 0; col BOARD_WIDTH; col) { if (grid[row][col] 0) { full 0; break; } } if (full) { for (int r row; r 0; r--) { for (int col 0; col BOARD_WIDTH; col) { grid[r][col] grid[r - 1][col]; } } for (int col 0; col BOARD_WIDTH; col) { grid[0][col] 0; } lines_cleared; row; } } return lines_cleared; }这个函数有一个隐蔽的小细节也是整个消除逻辑容易出错的地方最后一行row;配合循环里的row--相当于让row保持原值也就是消除当前行后还要用同一行索引重新检查一遍因为上面下移下来的行也可能是满行。如果把这一句漏掉连续两行同时满的情况就会只消掉一行。这种处理方式也顺便解决了多行同时消的情况不用递归也不用队列。4.4 渲染策略用system(cls)还是坐标定位控制台渲染是终端小游戏的共性问题。基础版本用system(cls)清屏然后重新打印界面简单直接但每一次调用都会造成明显的闪烁感。进阶版本使用 Windows 控制台的SetConsoleCursorPositionAPI把光标移回左上角重新画不闪屏观感好很多代价是代码里要#include windows.h平台绑定性强了可移植性变差。void gotoxy(int x, int y) { COORD pos {x, y}; HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); SetConsoleCursorPosition(hOut, pos); }COORD是 Windows API 定义的结构体x是列号y是行号画布左上角是 (0, 0)。如果是跨平台需求可以考虑把渲染层抽象成独立文件在 Windows 和 Linux 下分别实现一套代码的公共逻辑不做改动。对新手来说第一次跑通system(cls)版本之后再切换成坐标定位你会更容易理解“渲染”和“游戏逻辑”两个层次的边界。5. 50 行以内的小游戏实战贪吃蛇的环形链表5.1 贪吃蛇的最佳数据结构为什么是双端队列贪吃蛇这个游戏通常不会用简单数组存储整条蛇身因为每次移动时头和尾都在变。用双端队列来维护就特别合适头部前进时入队一个新坐标尾部不动时会自动出队一个老坐标整个蛇身自然向前移动。C 语言标准库不提供队列需要自己实现而实现的复杂度其实不高。typedef struct SnakeNode { int x; int y; struct SnakeNode *next; } SnakeNode; SnakeNode *snake_head NULL; SnakeNode *snake_tail NULL; void snake_move_to(int x, int y) { SnakeNode *new_head (SnakeNode *)malloc(sizeof(SnakeNode)); new_head-x x; new_head-y y; new_head-next snake_head; snake_head new_head; }这里的实现是头插法新节点成为新蛇头snake_head指向它原来的蛇头变成第二节点。注意snake_tail在这一阶段还没有更新它只有在吃食物时保持不变在普通移动时则要被移除。如果把链表画出来你会发现蛇头的移动其实只是在头部多了一个节点另外要做的就是根据是否吃食物来决定是否删除尾部节点。void snake_remove_tail(void) { if (snake_head NULL || snake_tail NULL) { return; } if (snake_head snake_tail) { free(snake_head); snake_head NULL; snake_tail NULL; return; } SnakeNode *cur snake_head; while (cur-next ! snake_tail) { cur cur-next; } cur-next NULL; free(snake_tail); snake_tail cur; }删除尾部节点的单链表写法需要先找到蛇尾的上一个节点然后把它更新为新的蛇尾。这个操作的时间复杂度是 O(n)在蛇长只有几十节的时候性能毫无压力。如果追求更高效率可以改造为双向链表让snake_tail直接通过prev找到前驱但代码可读性会下降。教学场景中单链表版本更容易理解也足够用。5.2 致命自撞判定坐标查链表与边界挡墙贪吃蛇有两个常见的死亡条件撞墙和咬到自己。撞墙的判断很简单超出地图边界就结束。咬到自己的判定在环形链表或单链表结构下需要遍历整条蛇身检查坐标是否重合。int check_collision(int x, int y) { SnakeNode *cur snake_head; while (cur ! NULL) { if (cur-x x cur-y y) { return 1; } cur cur-next; } return 0; }这个遍历每走一步都要执行一次时间复杂度是 O(n)。如果你发现游戏在蛇身较长时帧率明显下降可以考虑用一个二维数组来标记蛇身占用的格子每次移动时更新头尾两个格子这样碰撞检测就从 O(n) 降到 O(1)。需要注意同步问题数组方式和链表方式必须保持一致否则会出现链表判定碰撞但数组没有记录的情况。对于初学者先跑通链表版本再优化成数组版本感受一下数据结构选择对程序执行效率的影响。5.3 食物生成从随机坐标到避免重复生成食物生成的朴素做法是不断生成随机坐标直到找到一个空位为止但极端情况下蛇身几乎占满整个地图时这个算法会陷入长时间循环。更稳定的做法是维护一张空格子列表每次从列表中随机取一个位置。int empty_cells[1000][2]; int empty_count 0; void collect_empty_cells(int map_width, int map_height) { empty_count 0; for (int y 0; y map_height; y) { for (int x 0; x map_width; x) { int occupied 0; SnakeNode *cur snake_head; while (cur ! NULL) { if (cur-x x cur-y y) { occupied 1; break; } cur cur-next; } if (!occupied) { empty_cells[empty_count][0] x; empty_cells[empty_count][1] y; empty_count; } } } }empty_cells用数组存储空坐标行数固定为 1000这里假设地图上限是 30x30 也就是 900 格留出余量。empty_count在每次生成食物前被重新赋值确保列表里是最新的空位状态。随机取格子时在 0 到empty_count - 1之间取一个下标然后读取对应坐标即可。这种做法的好处是每局都不会因为随机数重复而产生不死循环在蛇身较长时显得特别稳定。5.4 键盘响应一次性读取 vs 阻塞等待控制台小游戏在键盘处理上要改掉一个习惯标准库的scanf和getchar会阻塞等待回车游戏运行中不能用它们来读取方向键。在 Windows 上方案是用_kbhit()配合_getch()前者检查键盘缓冲区是否有输入后者立即读取一个字符不等待回车。#include conio.h int get_direction(int current_dir) { if (_kbhit()) { int ch _getch(); if (ch 224 || ch 0) { ch _getch(); switch (ch) { case 72: return 1; case 80: return 2; case 75: return 3; case 77: return 4; } } else if (ch w || ch W) { return 1; } else if (ch s || ch S) { return 2; } else if (ch a || ch A) { return 3; } else if (ch d || ch D) { return 4; } } return current_dir; }这里_getch()第一次返回224或0表示探测到方向键需要再读一次才能获得真正的按键码这是conio.h在 Windows 上的特有行为。72 80 75 77分别对应上、下、左、右四个方向键的扫描码。同时还兼容了WASD作为替代操作方便笔记本无独立方向键的用户。这个函数放在游戏主循环里每次渲染前调用能实现“按住方向键持续响应”的效果。6. 代码风格和交付细节像开源项目一样整理你的源码包6.1 给控制台程序补上退出入口很多初学者写的游戏程序没有退出的方式只能靠强制关闭窗口。一个成熟的控制台程序应该有统一的菜单开始游戏、查看说明、退出退出前释放所有动态分配的内存。int show_menu(void) { int choice; printf(1. 开始游戏\n); printf(2. 操作说明\n); printf(3. 退出\n); printf(请输入选项: ); scanf(%d, choice); while (getchar() ! \n); return choice; }while (getchar() ! \n);这一行至关重要它的作用是清空输入缓冲区中残留的回车符。如果你在scanf之后紧接着调用_getch()或getchar()来读取下一次按键回车符会直接被读走导致程序看起来像“跳过了按键”。很多初学者在这个问题上卡很久实际就是缓冲区没清干净。6.2 数值合法性校验阻断越界和脏输入游戏代码里最容易出安全问题的是玩家输入的坐标和选项编号。一个五子棋程序如果玩家输入了 18 行 5 列或者输入了字母a程序就崩溃显然不合格。在做正式交付前建议给所有整数输入加上校验循环。int read_int_in_range(const char *prompt, int min, int max) { int value; while (1) { printf(%s, prompt); if (scanf(%d, value) 1 value min value max) { while (getchar() ! \n); return value; } printf(输入无效请输入 %d 到 %d 之间的整数\n, min, max); while (getchar() ! \n); } }scanf的返回值是成功匹配的变量个数等于 1 才说明读到了整数。如果用户输入的是abcscanf返回 0循环继续同时while (getchar() ! \n);把abc和后面的换行全部读走避免下次scanf继续读到同一个脏数据。这个函数封装好后菜单选项、棋盘落子、游戏尺寸选择都可以复用可读性和健壮性同时提升。6.3 通用 Makefile 和调试选项拿到源码包后很多初学者直接用 IDE 运行这样其实掩盖了编译过程的许多细节。用命令行加 Makefile 跑一遍能更快发现头文件路径、链接库依赖这类问题。CC gcc CFLAGS -Wall -Wextra -stdc11 LDFLAGS all: tetris chess calendar tetris: tetris.c $(CC) $(CFLAGS) -o tetris tetris.c $(LDFLAGS) chess: chess.c $(CC) $(CFLAGS) -o chess chess.c $(LDFLAGS) calendar: calendar.c $(CC) $(CFLAGS) -o calendar calendar.c $(LDFLAGS) clean: rm -f tetris chess calendar把不同游戏拆成独立目标文件的好处是修改其中一个源码不会导致其他目标重编。-Wall -Wextra在编译阶段会输出所有警告比如未使用变量、比较符号位等问题都会显示不要忽略这些警告很多潜在 bug 在警告里已经露馅了。在有conio.h或windows.h依赖的源文件上LDFLAGS可能需要追加-lconio或其他库具体视环境而定。6.4 代码中的字节序和编码问题中文版控制台游戏在 Windows 上经常遇到的乱码问题其实跟代码逻辑无关而是源文件保存编码与终端代码页不匹配。Windows 控制台默认的代码页在中国区通常是 936 即 GBK如果你用 UTF-8 保存源码文件其中的中文字符串打印出来就会是乱码。解决方案有两种把源文件另存为 ANSI 编码或者在程序开头调用SetConsoleOutputCP(65001)把代码页切到 UTF-8。第二种方案需要#include windows.h但跨平台性就会受影响而把源文件保存为 ANSI 编码的做法在 Linux 下反而会出问题。#ifdef _WIN32 #include windows.h #endif int main(void) { #ifdef _WIN32 SetConsoleOutputCP(65001); #endif // 游戏主逻辑 return 0; }在调试之前先把编码问题解决掉不然排查了半天逻辑最后发现所有问题都出在显示上。用 Git 管理代码时建议在仓库根目录放一份.gitattributes文件明确标记文本编码这样换机器拉代码时不会因为系统默认编码不同而出现同样的乱码问题。本文还有配套的精品资源点击获取