直接说结论:如果你已经会写最基础的“猜数字”了,那这篇教程就是给你跨到“能拿得出手的C语言小项目”的那一级台阶。基础版一般就是随机数加scanf加if,写出来能跑,但压根谈不上工程感。而进阶版要解决的问题很明确:输入非法时不崩、游戏循环有策略、成绩能留下来、代码拆成函数而不是一坨main。这篇文章我按实际开发的思路,从设计讲到源码再讲到踩坑,全部基于C语言,只讲干货,每一个函数都告诉你为什么要这么写。
1. 进阶版和基础版,差别到底在哪
1.1 先看看基础版猜数字通常长什么样
我相信大多数初学者的第一个猜数字程序都是这样的流程:程序生成一个1到100的随机数,玩家不断输入数字,程序提示“大了”或“小了”,直到猜中为止。
核心代码往往就十几行:
#include <stdio.h> #include <stdlib.h> #include <time.h> int main() { int target, guess; srand((unsigned)time(NULL)); target = rand() % 100 + 1; do { printf("请输入你猜的数字:"); scanf("%d", &guess); if (guess > target) printf("大了\n"); else if (guess < target) printf("小了\n"); else printf("中了\n"); } while (guess != target); return 0; }这个版本能跑,但问题很明显。第一,你输入字母或者小数点,scanf直接失灵,程序进入死循环。第二,没有难度选择,从头到尾都是同一个区间。第三,猜完就结束,没有任何记录。第四,所有逻辑全堆在main里,项目稍微加功能就乱套。
1.2 进阶版做的四件事
进阶版不是把代码变长那么简单,而是围绕真实开发中最常见的四个需求展开。
第一件事是输入校验。用户永远会输入你预想不到的内容,正规程序必须在输入环节做防御。进阶版里我会写一个readInt函数,采用“读一行再解析”的方式,把所有非法输入挡在门外。
第二件事是游戏可配置。我设计了三个难度等级,每个等级对应不同的数字范围、不同的最大尝试次数。这样玩家可以选择挑战,而不是永远在一个简单区间里循环。
第三件事是过程可视化。每猜一次,程序会列出之前所有猜测的数字和大小方向,玩家能清楚地看到自己的猜测轨迹,这对分析策略很有帮助。
第四件事是数据持久化。每局结束,结果写入到本地的二进制文件里,下次启动程序可以查看历史上每局的目标数、用了几次、输赢情况和时间。这一下就把小游戏提升到了“具备基本存档功能”的层次。
1.3 技术选型和工具准备
这个项目用到的东西全部是C语言标准库内容:stdio、stdlib、time、string,不依赖任何第三方库,也不依赖特定操作系统。
编译环境我用的是某个常见的国产开源编译器,Windows和Linux都可以直接跑。如果你用的是别的编译器,完全没有区别。唯一要注意的是源文件编码,我建议统一保存为UTF-8,否则Windows老版本控制台可能出现中文乱码,这个后面我会专门讲。
2. 工程骨架与数据结构设计
2.1 功能模块怎么划分
一个稍微像样的C程序,一定要做好模块拆分。这个猜数字项目我按功能拆成六块:
- 菜单模块:负责显示选项和处理用户选择
- 输入模块:负责安全地读取整数
- 游戏模块:负责单局游戏完整流程
- 随机数模块:负责按区间生成目标数字
- 记录模块:负责把战绩写入文件
- 历史查看模块:负责读取文件并展示统计信息
每个模块对应一到两个函数。这样写的好处是main函数只负责调度,每个函数单独好调试,出问题定位块,代码可读性也大幅提升。
2.2 用结构体管理难度和战绩
很多初学者写代码喜欢用散落的变量,比如min1、max1、attempts1,然后第二档再写min2、max2。这种写法在项目小的时候能忍,一旦功能变多就是灾难。
进阶版我采用两个结构体来组织数据。第一个结构体表示难度配置:
typedef struct { int level; char name[16]; int min; int max; int attempts; } Difficulty;字段含义很直白:level是难度编号,name是难度名称,min和max是随机数上下限,attempts是最大尝试次数。
第二个结构体表示一条战绩记录:
typedef struct { int diffLevel; char diffName[16]; int target; int usedAttempts; int maxAttempts; int win; time_t playTime; } GameRecord;这个结构体保存了一局游戏的全部关键信息:难度信息、目标数、实际用了几次、最大允许次数、是否猜中以及游戏时间。
然后我用一个全局数组把三档难度一次性定义好:
Difficulty levels[3] = { {1, "简单", 1, 50, 10}, {2, "普通", 1, 100, 8}, {3, "困难", 1, 200, 6} };2.3 为什么用二进制文件保存战绩
保存战绩的方案其实有几种选择,最常见的是文本文件或者二进制文件。
文本文件的优点是肉眼可读,用记事本打开就能看到内容。缺点也明显:写入时要做格式化,读取时要写解析函数。如果你是给别的程序看,那文本文件合适;但这里纯粹是让猜数字程序自己读写,用二进制文件反而更简单。
在C语言里,fwrite函数可以直接把一个结构体变量的内存原样写入文件,fread函数又能原样读回来。结构体数组、结构体字段、时间戳这些东西不需要任何转换,一个函数调用就搞定。
二进制文件的好处是读写效率高、代码量少。坏处是别人直接打开文件是乱码,但那是正常的,这个文件本来就不是给人读的,而是给程序读的。
3. 核心代码逐段解析
3.1 输入校验函数:挡住一切非法输入
前面我提到基础版用scanf读取数字,输入字母就死循环。这个问题的根源在scanf的缓冲区机制。
scanf("%d", &guess)在读入失败时不会清空缓冲区,失败的内容还留在缓冲区里,下次循环scanf又读到同样的非法字符,再次失败,于是就死循环了。
进阶版的readInt函数采用“读一行,再解析”的方式。首先用fgets读入一整行字符串,然后用sscanf尝试从字符串里解析整数。只要解析失败或者行内有多余字符,就重新提示用户输入,绝不把垃圾数据留到下一次。
int readInt(const char *prompt) { char line[64]; int value; char tail = 0; int n; while (1) { printf("%s", prompt); if (fgets(line, sizeof(line), stdin) == NULL) { clearerr(stdin); continue; } n = sscanf(line, "%d%c", &value, &tail); if (n == 1) { return value; } if (n == 2 && (tail == '\n' || tail == '\r' || tail == ' ')) { return value; } printf("输入无效,请重新输入。\n"); } }这段代码有几个细节要细品。第一个细节是sscanf的返回值,它返回成功赋值的变量个数。如果返回1,说明这一行只有一个整数,没有多余内容。如果返回2,说明整数后面还读到了一个字符,这个字符被存到tail里。我判断一下tail是不是换行或者空格,是就接受,不是就说明用户输入了“123abc”这种东西,那就拒绝。
第二个细节是fgets没有读到内容时的处理。如果输入流出错,fgets返回NULL,我调用clearerr清除错误状态,然后继续循环重新读取。
第三个细节是我给readInt留了一个prompt参数,这样调用方可以自己定义提示文字,函数本身更通用。
不使用scanf直接读取,加上这个严格校验,你就再也不用担心用户乱输入导致程序崩溃了。这个函数是整个项目的地基,后面的所有输入都靠它。
3.2 随机数生成与种子初始化
随机数本身很简单,但有三个细节必须注意。
第一,rand函数生成的是伪随机数。如果你不设置种子,每次运行程序生成的随机序列都是一样的,那猜数字就没意义了。所以main函数里必须有这一句:
srand((unsigned)time(NULL));用当前时间作为随机种子。时间每秒钟都在变化,每次运行程序生成的序列自然不同。
第二,rand() % (max - min + 1) + min这个公式可以生成指定区间的随机数。比如要生成1到50的随机数,就是rand() % 50 + 1。要生成1到200,就是rand() % 200 + 1。
第三,也是我经常在现场遇到的问题,就是“同一个程序里连续调用多次生成随机数,结果一样”的现象。这个现象通常是你在同一秒内多次调用srand导致的。所以srand一定要放在程序最开头只调用一次,不要在游戏循环里反复srand。
我把随机数生成封装成一个函数,方便调用,也方便以后替换更均匀的随机算法:
int genTarget(int min, int max) { return rand() % (max - min + 1) + min; }3.3 游戏主流程:循环、范围校验与历史轨迹
游戏主流程是整个项目的核心。我把完整函数贴出来,然后再拆开讲:
void playGame() { int level; int guess; int attempts = 0; int hist[64]; int target; int min, max, maxAttempts; printf("\n请选择难度:\n"); printf(" 1. 简单(1~50,10次机会)\n"); printf(" 2. 普通(1~100,8次机会)\n"); printf(" 3. 困难(1~200,6次机会)\n"); level = readInt("输入难度编号:"); while (level < 1 || level > 3) { level = readInt("编号无效,重新输入(1~3):"); } min = levels[level - 1].min; max = levels[level - 1].max; maxAttempts = levels[level - 1].attempts; target = genTarget(min, max); printf("\n====== 本局配置 ======\n"); printf("难度:%s | 范围:[%d, %d] | 最多尝试:%d次\n", levels[level - 1].name, min, max, maxAttempts); while (attempts < maxAttempts) { printf("\n已用次数:%d | 剩余次数:%d\n", attempts, maxAttempts - attempts); guess = readInt("请输入你的猜测:"); if (guess < min || guess > max) { printf(">> %d 不在 %d~%d 范围内,本次不计次数。\n", guess, min, max); continue; } hist[attempts++] = guess; if (guess == target) { int score = 100 - (attempts - 1) * 10; printf("\n★★★ 猜中!目标数字就是 %d ★★★\n", target); printf("共用 %d 次机会,本局得分 %d。\n", attempts, score); saveRecord(level, target, attempts, 1); return; } else if (guess < target) { printf(">> 数字太小了。\n"); } else { printf(">> 数字太大了。\n"); } printf("已猜记录:"); for (int i = 0; i < attempts; i++) { if (i > 0) printf(", "); if (hist[i] < target) printf("%d(小)", hist[i]); else if (hist[i] > target) printf("%d(大)", hist[i]); else printf("%d(中)", hist[i]); } printf("\n"); } printf("\n次数用尽。本局目标数字为 %d。\n", target); saveRecord(level, target, attempts, 0); }这段代码最核心的设计点有三个。
第一个是范围校验配合continue。用户输入一个不在当前难度区间的数字,系统会提示超出范围,但不会消耗他的猜测次数。这个设计很符合游戏直觉:你没有真正去猜,只是输错了,凭什么扣次数?如果你用基础版那种直接进入比较逻辑的写法,用户误输一个200在简单难度里,直接就被判“太大了”,白瞎一次机会。
第二个是hist数组保存历史猜测。我在循环开始前定义了一个int hist[64],玩家每猜一次,就把数字存进去。然后在每次猜完之后,遍历整个数组,把每个猜测和它对应的方向打印出来。玩家可以一眼看出“我第一把猜20小了,第二把猜40大了,现在应该猜30”。这种过程复盘能力是基础版完全没有的体验。
第三个是计分规则。我定义得分从100开始,每多猜一次扣10分。第一次猜中100分,第二次90分,依此类推。这个规则简单但公平,也给玩家一个除了“猜没猜中”之外的另一层目标。
有一点需要注意:hist数组的上限我写死了64,而最多尝试次数是10次,所以理论上不会越界。但如果你以后要加更高难度,记得把数组上限一并调大,或者直接定义成和最大尝试次数一致。
3.4 战绩保存与历史查看
战绩保存的逻辑不复杂,就是构造一个GameRecord结构体变量,然后把整个结构体写入文件。
void saveRecord(int level, int target, int used, int win) { Difficulty d = levels[level - 1]; GameRecord rec; rec.diffLevel = d.level; strcpy(rec.diffName, d.name); rec.target = target; rec.usedAttempts = used; rec.maxAttempts = d.attempts; rec.win = win; rec.playTime = time(NULL); FILE *fp = fopen(HISTORY_FILE, "ab"); if (!fp) { printf("警告:无法写入历史文件 %s\n", HISTORY_FILE); return; } fwrite(&rec, sizeof(GameRecord), 1, fp); fclose(fp); }打开文件的方式是"ab"模式。a表示追加,也就是说每次新战绩追加到文件末尾,不会覆盖旧记录。b表示二进制模式。注意,在Windows下这个b很重要,如果没有b,文件可能会被特殊处理,导致写入的结构体数据出现额外字节。
历史查看函数相对长一点,因为要读取文件、格式化时间、统计胜率:
void showHistory() { FILE *fp = fopen(HISTORY_FILE, "rb"); GameRecord rec; int count = 0; int wins = 0; char timeStr[32]; if (!fp) { printf("\n暂无历史记录,先去赢一局吧。\n"); return; } printf("\n========= 历史战绩 =========\n"); while (fread(&rec, sizeof(GameRecord), 1, fp) == 1) { count++; if (rec.win) wins++; struct tm *tm = localtime(&rec.playTime); strftime(timeStr, sizeof(timeStr), "%m-%d %H:%M", tm); printf("No.%-3d | %-5s | 目标:%-4d | 用次:%-2d/%-2d | %s | %s\n", count, rec.diffName, rec.target, rec.usedAttempts, rec.maxAttempts, rec.win ? "胜利" : "失败", timeStr); } fclose(fp); printf("------------------------------\n"); printf("总局数:%d | 胜场:%d | 胜率:%.1f%%\n", count, wins, count > 0 ? wins * 100.0 / count : 0.0); }这里有几个易错点。
第一个是time_t到可读字符串的转换。time_t本质是一个整数时间戳,直接打印没有意义。必须先调用localtime把时间戳转成struct tm结构体,再用strftime格式化成“月-日 时:分”的字符串。
第二个是fread的返回值。fread返回读取的元素个数,正好可以用一次读取是否等于1来判断是否读到了有效数据。读到文件末尾时返回0,循环自然结束。
第三个是胜率计算。我写的是count > 0 ? wins * 100.0 / count : 0.0。注意wins * 100.0这里必须有一个浮点数参与运算,否则整数除以整数会得到整数结果,万分之万会出现“胜率永远是0”的经典bug。
3.5 主菜单与调度逻辑
main函数里最核心的是一层循环和switch式的分支判断。不过这里我用了if else,因为逻辑只有四个分支,简单直接:
int main() { int choice; srand((unsigned)time(NULL)); while (1) { printf("\n======= 进阶版猜数字 =======\n"); printf(" 1. 开始新游戏\n"); printf(" 2. 查看历史战绩\n"); printf(" 3. 游戏说明\n"); printf(" 0. 退出\n"); printf("============================\n"); choice = readInt("请选择:"); if (choice == 1) playGame(); else if (choice == 2) showHistory(); else if (choice == 3) showHelp(); else if (choice == 0) { printf("再见,欢迎再来挑战!\n"); break; } else { printf("无效选项,请重新输入。\n"); } } return 0; }这里的设计意图是:菜单循环不设退出上限,玩家可以连续开很多局,每局结束后回到菜单再看战绩或者继续新游戏。这种设计在命令行程序里非常常见,也是基础版那种玩一局就退出的程序无法实现的交互体验。
4. 可以直接运行的完整源码
把上面所有模块拼到一起,就是一份完整的可运行源码。我这里贴出完整版本,注释也加了,方便你直接对照。
#include <stdio.h> #include <stdlib.h> #include <time.h> #include <string.h> #define HISTORY_FILE "guess_history.dat" typedef struct { int level; char name[16]; int min; int max; int attempts; } Difficulty; typedef struct { int diffLevel; char diffName[16]; int target; int usedAttempts; int maxAttempts; int win; time_t playTime; } GameRecord; Difficulty levels[3] = { {1, "简单", 1, 50, 10}, {2, "普通", 1, 100, 8}, {3, "困难", 1, 200, 6} }; int readInt(const char *prompt) { char line[64]; int value; char tail = 0; int n; while (1) { printf("%s", prompt); if (fgets(line, sizeof(line), stdin) == NULL) { clearerr(stdin); continue; } n = sscanf(line, "%d%c", &value, &tail); if (n == 1) { return value; } if (n == 2 && (tail == '\n' || tail == '\r' || tail == ' ')) { return value; } printf("输入无效,请重新输入。\n"); } } int genTarget(int min, int max) { return rand() % (max - min + 1) + min; } void saveRecord(int level, int target, int used, int win) { Difficulty d = levels[level - 1]; GameRecord rec; rec.diffLevel = d.level; strcpy(rec.diffName, d.name); rec.target = target; rec.usedAttempts = used; rec.maxAttempts = d.attempts; rec.win = win; rec.playTime = time(NULL); FILE *fp = fopen(HISTORY_FILE, "ab"); if (!fp) { printf("警告:无法写入历史文件 %s\n", HISTORY_FILE); return; } fwrite(&rec, sizeof(GameRecord), 1, fp); fclose(fp); } void showHistory() { FILE *fp = fopen(HISTORY_FILE, "rb"); GameRecord rec; int count = 0; int wins = 0; char timeStr[32]; if (!fp) { printf("\n暂无历史记录,先去赢一局吧。\n"); return; } printf("\n========= 历史战绩 =========\n"); while (fread(&rec, sizeof(GameRecord), 1, fp) == 1) { count++; if (rec.win) wins++; struct tm *tm = localtime(&rec.playTime); strftime(timeStr, sizeof(timeStr), "%m-%d %H:%M", tm); printf("No.%-3d | %-5s | 目标:%-4d | 用次:%-2d/%-2d | %s | %s\n", count, rec.diffName, rec.target, rec.usedAttempts, rec.maxAttempts, rec.win ? "胜利" : "失败", timeStr); } fclose(fp); printf("------------------------------\n"); printf("总局数:%d | 胜场:%d | 胜率:%.1f%%\n", count, wins, count > 0 ? wins * 100.0 / count : 0.0); } void playGame() { int level; int guess; int attempts = 0; int hist[64]; int target; int min, max, maxAttempts; printf("\n请选择难度:\n"); printf(" 1. 简单(1~50,10次机会)\n"); printf(" 2. 普通(1~100,8次机会)\n"); printf(" 3. 困难(1~200,6次机会)\n"); level = readInt("输入难度编号:"); while (level < 1 || level > 3) { level = readInt("编号无效,重新输入(1~3):"); } min = levels[level - 1].min; max = levels[level - 1].max; maxAttempts = levels[level - 1].attempts; target = genTarget(min, max); printf("\n====== 本局配置 ======\n"); printf("难度:%s | 范围:[%d, %d] | 最多尝试:%d次\n", levels[level - 1].name, min, max, maxAttempts); while (attempts < maxAttempts) { printf("\n已用次数:%d | 剩余次数:%d\n", attempts, maxAttempts - attempts); guess = readInt("请输入你的猜测:"); if (guess < min || guess > max) { printf(">> %d 不在 %d~%d 范围内,本次不计次数。\n", guess, min, max); continue; } hist[attempts++] = guess; if (guess == target) { int score = 100 - (attempts - 1) * 10; printf("\n★★★ 猜中!目标数字就是 %d ★★★\n", target); printf("共用 %d 次机会,本局得分 %d。\n", attempts, score); saveRecord(level, target, attempts, 1); return; } else if (guess < target) { printf(">> 数字太小了。\n"); } else { printf(">> 数字太大了。\n"); } printf("已猜记录:"); for (int i = 0; i < attempts; i++) { if (i > 0) printf(", "); if (hist[i] < target) printf("%d(小)", hist[i]); else if (hist[i] > target) printf("%d(大)", hist[i]); else printf("%d(中)", hist[i]); } printf("\n"); } printf("\n次数用尽。本局目标数字为 %d。\n", target); saveRecord(level, target, attempts, 0); } void showHelp() { printf("\n======== 游戏说明 ========\n"); printf("1. 游戏开始后,系统会在难度范围内生成一个随机数。\n"); printf("2. 你每次输入一个整数,系统会提示偏大或偏小。\n"); printf("3. 超出范围的输入不会消耗次数。\n"); printf("4. 每猜一次会显示之前所有猜测与大小方向。\n"); printf("5. 猜中或次数用完,结果会自动保存到历史文件。\n"); printf("========================\n"); } int main() { int choice; srand((unsigned)time(NULL)); while (1) { printf("\n======= 进阶版猜数字 =======\n"); printf(" 1. 开始新游戏\n"); printf(" 2. 查看历史战绩\n"); printf(" 3. 游戏说明\n"); printf(" 0. 退出\n"); printf("============================\n"); choice = readInt("请选择:"); if (choice == 1) playGame(); else if (choice == 2) showHistory(); else if (choice == 3) showHelp(); else if (choice == 0) { printf("再见,欢迎再来挑战!\n"); break; } else { printf("无效选项,请重新输入。\n"); } } return 0; }这份代码我实际编译运行过,功能正常。你可以直接复制到你的编译环境里,创建新文件保存,编译运行。
5. 编译运行与常见问题排查
5.1 第一次编译可能遇到的问题
如果你用的是Windows下的某个国产编译器,直接新建源文件、粘贴代码、点编译运行,大概率一次通过。
如果编译报错,最可能的原因有三个。第一,源文件编码不是UTF-8,导致中文注释或中文字符串乱码。第二,编译器标准太老,不支持for循环内声明变量,把int i;提到函数开头就行。第三,头文件路径有问题,但这种情况很少。
如果你用的是在线练习平台,注意平台是否支持写入本地文件。有些在线平台的文件系统是临时的,每次运行程序都是全新环境,那战绩保存功能就没法正常工作,但程序仍能运行。
5.2 运行效果大致是什么样
你启动程序后,会看到主菜单。选1开始新游戏,然后选难度。比如选2普通难度,程序会提示范围是1到100,最多8次机会。
你输入50,程序提示数字太小,并且列出“已猜记录:50(小)”。你输入75,程序提示数字太大,并且列出“已猜记录:50(小), 75(大)”。你输入62,如果目标正好是62,程序会显示猜中,给出得分,同时自动把这一局保存到历史文件。
退出游戏重新启动,选择2查看历史战绩,就能看到之前那局的完整记录和胜率统计。
5.3 中文乱码怎么解决
这是Windows老版控制台最常见的问题。代码里的中文字符串在源文件里保存为UTF-8,但Windows控制台默认代码页是GBK,二者不一致,显示出来就是乱码。
解决办法有两个。一个是在源文件开头加一句:
#pragma execution_character_set("utf-8")但这只对某些编译器有效,而且兼容性一般。
另一个更稳妥的办法是在运行程序之前,在控制台执行:
chcp 65001把控制台代码页临时切换到UTF-8,这样中文显示就正常了。如果你用的是较新版本的Windows或者终端工具,默认就是UTF-8,不会遇到这个问题。
5.4 随机数每次都一样怎么回事
很多初学者在调试时会发现:每次启动程序,目标数字都一样。这个问题的根源就是没有设置随机种子,或者srand被放在了循环里。
记住两条原则。第一,srand在整个程序生命周期里只调用一次,放在main开头。第二,srand的参数最好用time(NULL),因为这个值每秒变化一次。如果你的程序在同一秒内短时间反复启动,理论上随机种子可能相同,但那是极小概率事件,不影响实际使用。
5.5 历史文件保存失败
如果你运行程序后提示“无法写入历史文件”,先检查程序所在目录有没有写入权限。在Windows下,如果程序放在C盘系统的Program Files目录里,普通用户经常没有写入权限。解决方案是把程序放到自己的工作目录,或者以管理员身份运行。
第二个可能性是历史文件被其他程序占用。比如你用记事本打开了guess_history.dat,然后程序再去写入就可能失败。关闭文件再试就行。
第三个可能性是你改了HISTORY_FILE宏定义,把路径指向了一个不存在的目录。比如写成"D:\abc\guess_history.dat"但D盘没有abc文件夹,当然写不进去。C语言不会自动创建多级目录,需要你自己保证目录存在。
5.6 结构体写入文件后为什么越读越大
你可能会发现一个现象:反复把GameRecord写入文件后,文件大小一直在涨,这很正常,因为这就是追加模式。
但还有一种更隐蔽的问题,就是结构体在内存中存在padding填充字节。GameRecord里既有char数组又有int又有time_t,编译器为了保证成员对齐,会在结构体内部填充一些空白字节,导致sizeof(GameRecord)可能比你想象的字段总和更大。fwrite写的是整个结构体,这些填充字节也会写入文件,文件大小自然比“所有字段加起来”要大。这是正常的,同平台下fread也能正确读回。
如果你跨平台传输这个文件,比如在Windows编译的存档拿到Linux去读,有可能出现问题,因为不同平台的time_t大小和结构体对齐规则不完全一致。对这个项目来说这不是必改的问题,但你要心里有数。
5.7 输入一个超大数会发生什么
比如在简单难度,范围是1到50,你输入99999。程序会直接提示“99999不在1~50范围内,本次不计次数”,然后继续让你猜。这是好事,说明范围校验生效了。
但如果输入的是超过int范围的数字,比如999999999999999999,sscanf按%d解析会溢出,行为未定义。更保险的做法是把value类型改成long,用%ld解析,最后再转成int。我这里为了教学直观用了int,你如果要做得更严谨,可以自己改一下。
6. 我的实操心得与后续扩展方向
我带过不少同学做过这个项目,很多人做完基础版后就卡住了,主要是因为没人告诉他们“下一步还能加什么”。这个进阶版最大的价值不是某个单一功能,而是让你体会到:一个完整的小项目是如何从需求出发,拆出模块,然后一步步用代码落地的。
从代码结构上看,main函数只有几十行,核心逻辑全部拆了出去。如果你以后想加新功能,比如排行榜,你只需要加一个函数,在菜单里加一个分支,完全不需要动playGame的内部逻辑。这种可扩展性,才是“进阶版”真正的意义。
从编程技巧上看,readInt函数值得你反复琢磨。很多学C语言的人搞不懂scanf到底哪里不好,写了这个函数之后你可能才能真正理解缓冲区这个概念。以后再碰到任何需要控制台输入的程序,你都会下意识想到用fgets加sscanf而不是直接scanf,这就是实打实的进步。
关于后续扩展,方向很多。你可以加一个排行榜,按每局得分降序排列,只保留前10名。你可以加一个“最佳记录”,单独记录每个难度下用最少次数猜中的那局。你可以加一个简单的人工智能对手,让电脑根据二分法自动猜测,和你比谁更快。你甚至可以把二进制文件换成CSV文件,这样玩家就能用表格软件打开自己的战绩。
还有一个很小的细节,我个人觉得最值得做:在历史战绩里增加一个“平均猜中次数”。把每局胜场的usedAttempts加起来除以胜场数,就能看出你擅长的难度是哪个。这只是一个统计字段的扩展,但会让整个项目看起来更完整。
最后讲一个我踩过的坑。最初我写历史记录时,用的是文本模式"a"而不是二进制追加模式"ab"。在Windows上,以文本模式写入时,每次遇到换行符会被自动转换成回车加换行两个字节,读的时候再转换回来。对于文本内容没问题,但结构体里的二进制数据会被这个转换破坏,导致读取时结构体字段错位。我排查了很久才发现是模式字母没加b。之后我就长记性了,只要用fwrite和fread就一定是二进制模式,绝对不用文本模式。
如果你把这个项目完整跑通了,建议你自己动手改一个地方再重新编译:把三个难度的数字范围做成玩家自定义输入。这个改动不难,但要动playGame函数里的逻辑和levels数组的访问方式。能做到这一点,说明你真正理解了这段代码,而不是只会复制粘贴。