石头剪刀布是每个程序员都该写一遍的入门项目:它麻雀虽小,却把随机数、输入校验、状态枚举、胜负判定这些 C 语言基本功全串起来了。本文用纯标准库实现一个能连续对战、能统计战绩的人机版,全程无第三方依赖,gcc 直接编译就能玩。

1. 游戏规则

经典规则不用多说:石头(1)赢剪刀(2),剪刀赢布(3),布赢石头,相同则平局。每局玩家输入 1、2、3 出拳,电脑随机出拳,程序判定胜负并累计比分;输入 0 结束对局,最后打印总战绩和胜率。

为什么用数字而不是字符?因为 scanf("%d") 读整数时会自动跳过空白字符,处理输入比读字符简单,也不容易被换行符坑到。我们把三个手势和数字的对应关系定死:

数值含义
0退出游戏
1石头
2剪刀
3布

对局流程是:每局先读玩家的选择,电脑立刻随机出拳,双方亮拳后判定胜负、更新比分,然后进入下一局。循环直到玩家输入 0 退出,最后打印"共 N 局:胜 X 负 Y 平 Z"和胜率。整个流程里,"读输入"和"判胜负"是两个最容易出 bug 的地方,后面我们会分别重点处理。

2. 模块拆解

游戏拆成三个函数,职责分明:

主函数 main() 负责循环对局和统计比分。先看随机数和出拳:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

/* 电脑随机出拳:返回 1~3 */
int computer_choice(void) {
    return rand() % 3 + 1;
}

int main(void) {
    srand((unsigned)time(NULL));   /* 用当前时间做随机种子 */
    printf("电脑出拳:%d\n", computer_choice());
    return 0;
}

这里有个 C 语言经典坑:rand() 是"伪随机",如果不调用 srand() 播种,每次运行程序生成的序列都一模一样。用 time(NULL) 返回的秒数做种子,每次运行才不同。rand() % 3 + 1 把随机数映射到 1~3,注意 % 的优先级高于 +,所以它等价于 (rand() % 3) + 1,别脑补成 rand() % (3 + 1)。

把"电脑出拳"抽成独立函数还有个好处:想测试时可以直接调用它,看看随机数分布是否均匀——理论上 1、2、3 出现的次数应该差不多。写一个小循环跑一万次,统计三个数字的出现次数,你就能直观感受到 rand() 的分布质量。

3. 逐步实现:输入校验

玩家可能输入 5、-1 甚至字母,程序必须挡住这些非法值。校验逻辑抽成一个纯函数,好测试也好复用:

#include <stdio.h>

/* 判断玩家输入是否合法:0~3 */
int valid_choice(int p) {
    return p >= 0 && p <= 3;
}

int main(void) {
    printf("%d\n", valid_choice(2));   /* 合法 */
    printf("%d\n", valid_choice(9));   /* 非法 */
    printf("%d\n", valid_choice(-1));  /* 非法 */
    return 0;
}

真正在游戏里,我们要把"读入 + 校验 + 重试"写进一个 while 循环,只要不合法就一直问,直到拿到合法值为止——这样调用方拿到的数据永远是干净的。完整写法见第 6 节。另一个隐藏坑是 scanf("%d", &p) 必须传 &p(取地址),漏掉 & 是"编译能过、运行崩溃"的头号原因。

上面这段只是验证 valid_choice 的纯逻辑。真实游戏里读输入必须处理一个刁钻情况:玩家输入字母时,scanf 读不到整数,字母会一直留在缓冲区,导致后面每次 scanf 都失败,程序看起来像卡死。对策有两个:要么检查 scanf 的返回值,要么用 fgets 读整行再用 sscanf 解析——完整代码里我们选了前者。

4. 核心算法:胜负判定

胜负判定最直观的写法是嵌套 if,但用 查表法更优雅:把 4x4 的结果表直接写进代码,result[玩家][电脑] 一次下标就能拿到结果:

#include <stdio.h>

/* result[玩家][电脑]:1 玩家赢, -1 电脑赢, 0 平局 */
static const int result[4][4] = {
    {0, 0, 0, 0},
    {0, 0, 1, -1},   /* 玩家出石头:赢剪刀,输布 */
    {0, -1, 0, 1},   /* 玩家出剪刀:输石头,赢布 */
    {0, 1, -1, 0},   /* 玩家出布:赢石头,输剪刀 */
};

int judge(int player, int computer) {
    return result[player][computer];
}

int main(void) {
    printf("石头vs剪刀:%d\n", judge(1, 2));
    printf("剪刀vs布:%d\n", judge(2, 3));
    printf("布vs石头:%d\n", judge(3, 1));
    return 0;
}

查表法把"逻辑"变成了"数据":以后再想升级成"石头剪刀布蜥蜴史波克"这种 5 种手势的版本,只需把表扩成 6x6,judge 函数一行都不用改。static const 保证数组只初始化一次且不可修改;result[0][x] 整行都是 0,是因为 0 号位代表"退出",永远不会作为出拳传入。

对比一下两种写法的差别:嵌套 if 版的判定逻辑散落在代码里,加一种新手势就要改好几处;查表版把所有可能性集中在一张表里,改表就是改规则,可读性反而更高。这也是为什么游戏开发里"数据驱动"的理念如此流行——规则是数据,代码只是解释数据的引擎。

5. 主循环与战绩统计

对局循环的结构是"先读输入,是 0 就退出,否则出拳、判定、计数"。计数部分和胜率公式先单独看:

#include <stdio.h>

int main(void) {
    int round = 7, win = 4, lose = 2, draw = 1;

    printf("共 %d 局:胜 %d 负 %d 平 %d\n", round, win, lose, draw);
    /* 注意:必须写 100.0 而不是 100 */
    printf("胜率: %.1f%%\n", 100.0 * win / round);
    return 0;
}

算胜率时有个 C 陷阱:100.0 * win / round 必须以浮点数开头。如果写成 100 * win / round,整数除法会把小数部分直接截掉,胜率永远显示 0%。%.1f 保留 1 位小数,%% 转义出一个真正的百分号。

实际的对局循环里,这三个计数器在每局结束后更新,round 每局加一。把循环放在 while (1) 里、靠 break 退出,是 C 里写"菜单式交互"的经典结构——很多命令行工具(配置向导、安装程序)都是这个骨架。比赛结束后可以自检一下:胜 + 负 + 平必须等于总局数,这是最简单的数据一致性验证。

6. 完整代码

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

static const int result[4][4] = {
    {0, 0, 0, 0},
    {0, 0, 1, -1},
    {0, -1, 0, 1},
    {0, 1, -1, 0},
};

int computer_choice(void) {
    return rand() % 3 + 1;
}

int judge(int player, int computer) {
    return result[player][computer];
}

void show(int c) {
    switch (c) {
        case 1: printf("石头"); break;
        case 2: printf("剪刀"); break;
        case 3: printf("布");   break;
        default: break;
    }
}

int read_choice(void) {
    int p;
    while (1) {
        printf("输入 1(石头) 2(剪刀) 3(布) 0(退出): ");
        if (scanf("%d", &p) != 1) {
            /* 读不到整数:清空缓冲并返回 -1 表示异常 */
            while (getchar() != '\n')
                ;
            return -1;
        }
        if (p >= 0 && p <= 3)
            return p;
        printf("输入无效,请重新输入!\n");
    }
}

int main(void) {
    int round = 0, win = 0, lose = 0, draw = 0;
    srand((unsigned)time(NULL));

    printf("=== 石头剪刀布 ===\n");
    while (1) {
        int player = read_choice();
        if (player <= 0)      /* 0 退出,-1 表示输入异常 */
            break;

        int comp = computer_choice();
        printf("你出 ");
        show(player);
        printf(",电脑出 ");
        show(comp);
        printf("\n");

        int r = judge(player, comp);
        if (r == 1) {
            printf("你赢了!\n");
            win++;
        } else if (r == -1) {
            printf("你输了!\n");
            lose++;
        } else {
            printf("平局!\n");
            draw++;
        }
        round++;
    }

    printf("\n共 %d 局:胜 %d 负 %d 平 %d\n", round, win, lose, draw);
    if (round > 0)
        printf("胜率: %.1f%%\n", 100.0 * win / round);
    return 0;
}

编译运行:

gcc rps.c -o rps
./rps

这份代码里有两个值得细看的工程细节。第一,read_choice 检查了 scanf 的返回值:如果玩家输入了字母,scanf 返回 0 且字母残留在缓冲区,不处理的话会陷入"输入无效"的死循环,所以用 while (getchar() != '\n') 把缓冲区清空,再返回 -1 让主循环退出。第二,show() 用 switch 把数字翻译成中文,default 分支兜底,防止意外值。

运行效果大致是:程序先打印提示,你输入 2,它显示"你出剪刀,电脑出石头,你输了!",比分变成 0 胜 1 负。输入 0 后,程序打印总战绩并退出。如果你输入了字母,程序会提示输入异常并直接结束,而不是陷入死循环——这就是检查 scanf 返回值的价值。

7. 扩展想法

8. 总结与练习

这个项目浓缩了 C 语言的五个基本功:srand/rand 随机数、switch 分支、二维数组查表、scanf 输入校验、整数与浮点除法陷阱。别小看石头剪刀布,它把"输入 - 处理 - 输出"的完整链路走了一遍,是检验 C 基础是否扎实的试金石。

练习建议:

最后提醒:编译时如果看到 warning: implicit declaration of function 的警告,多半是漏了 #include <time.h> 或 <stdlib.h>;养成"用哪个函数就 include 哪个头文件"的习惯,能少踩很多坑。

💡 查表法是 C 语言里"用数据代替逻辑"的典型手法,在状态机、游戏 AI、配置解析里随处可见,值得养成这种思维。