深入解析mj-1.6:Unix环境C语言麻将游戏源码与跨平台实现
2026/9/14 11:27:42 网站建设 项目流程

简介:mj-1.6-src.zip 是一份面向 Unix 平台的棋牌游戏源码,明确兼顾 Windows/Unix 跨平台运行,适合游戏开发初学者与中级开发者研究麻将规则实现、界面交互及多平台兼容处理。包内共 239 个文件,以 192 个 xpm 图标贴图、13 个 C 源文件和 12 个头文件为主体,辅以 Perl 辅助脚本、Makefile 构建脚本、man 手册及 readme/licence 等文档,整体仅 374KB,结构紧凑。代码模块划分清晰:gui.c 与 gui-dial.c 负责图形界面与对话框,controller.c 调度游戏流程,game.c 与 tiles.c 管理牌局与牌张,greedy.c、scoring.c 实现智能出牌与计分,protocol.c、sysdep.c 涉及网络通信与系统适配,几乎覆盖棋牌游戏从界面到逻辑的完整链路。目前已有 105 人学习下载;借助这套源码,开发者可快速理解 Unix 网络编程、跨平台移植以及棋牌算法的落地路径,既能用于课程设计,也可作为自研项目的基础框架。对计划深入棋牌游戏内核的开发者而言,这份紧凑的代码库比大型引擎更适合作为解剖对象。

1. 从mj-1.6的源码结构看Unix棋牌游戏的模块边界

老牌的Unix程序员拿到mj-1.6-src.zip这类包时,多半会先做一件事:把它拖进终端,tar -xzf解压,然后用ls -l数一数有多少个 .c 文件。这个麻将源码包没有庞大的构建系统,也没有繁复的第三方依赖,只有十几个C文件,却把图形界面、网络协议、AI玩家、计分规则全塞进去了。你可以在Solaris、FreeBSD、Linux上把它编译起来,也能在Windows上用MinGW或者WSL把它跑通——这正是它最有价值的地方:一个结构清晰、可单步调试的棋牌游戏参考实现。

我拆过不少开源游戏,像麻将这种规则不复杂但边界条件极多的棋牌项目,往往比看起来难写。发牌要随机且不重复,胡牌要递归拆解,网络通信要处理半包和黏包,界面还要和游戏逻辑解耦。mj-1.6 把这堆事拆成了十几个模块,每个文件名一眼能看出职责:tiles 管牌,scoring 管分,protocol 管通信,sysdep 管跨平台。这篇博文就顺着这些文件逐个拆开,把关键算法和编译方法落到命令行上,适合想从零理解C语言游戏项目的中级开发者,也适合需要在老系统上维护C代码的工程师。

2. 跨平台适配层:sysdep.c 到底是干什么用的

2.1 Unix程序为什么需要 sysdep.c

很多Unix程序在写完第一版之后,发现Windows也能编译,但总有几处代码过不去:socket 初始化,Windows 需要WSAStartup,Unix 不需要;毫秒级延时,Windows 有Sleep,Unix 用usleep;随机数种子,Windows 用srand(GetTickCount()),Unix 用srand(time(NULL))。sysdep.c 就是专门封装这些差异的地方。它的头文件里通常写着#ifdef _WIN32这样的条件编译,把平台相关的系统调用统一成sys_前缀的函数。

这个文件虽然只有几百行,却决定了棋牌程序能不能在两个平台顺利编译。mj-1.6 把 sysdep.c 单独拎出来,而不是在 game.c 里到处写#ifdef,是很好的习惯。我在自己的项目里也这么干:把平台相关代码集中在同一层,后患少得多。

/* sysdep.h 中的典型定义 */ #ifdef _WIN32 #include <winsock2.h> #define sys_sleep(ms) Sleep(ms) #define sys_gettime_ms() GetTickCount() #else #include <sys/time.h> #include <unistd.h> #define sys_sleep(ms) usleep((ms) * 1000) #define sys_gettime_ms() (gettimeofday_x() / 1000) #endif

这里说明三点:第一,sys_sleep统一为毫秒参数,Windows 的Sleep和 Unix 的usleep单位不同,封装后调用方不用记;第二,sys_gettime_ms是给洗牌算法做随机种子用的,Unix 下用gettimeofday获取微秒时间,Windows 下直接用GetTickCount;第三,winsock2.h必须放在windows.h之前,否则会报一堆重定义错误,这是 Windows 网络编程最常见的坑。如果你在 Windows 下编译遇到WSAStartup相关连错误,就是没套这层。

2.2 网络socket的初始化差异

棋牌游戏需要联网的话,socket 初始化是跨平台适配的重头戏。Unix 下socket()bind()listen()直接用,Windows 下则要先调用WSAStartup。mj-1.6 通过sys_net_init()封装了这一步。我一般会在服务端入口的第一行调用它,在客户端入口同样调用,这样可以保证同一套代码在两个平台上行为一致。

int sys_net_init(void) { #ifdef _WIN32 WSADATA ws; if (WSAStartup(MAKEWORD(2, 2), &ws) != 0) { return -1; } #endif return 0; }

这段代码的意图是:非 Windows 平台直接返回 0,Windows 平台初始化 Winsock 2.2。注意MAKEWORD(2,2)指定的是版本号,Windows 95 之后的系统都支持。如果找不到这个函数,检查编译时是否链接了ws2_32:Linux 下用gcc -o mj *.c -lpthread,Windows 下用gcc -o mj.exe *.c -lws2_32。命令里的-l参数指定库名,Unix 的线程库pthread在 Windows 上不需要,但 Winsock 库必须显式链接。

2.3 文件的换行符和权限问题

源码包在 Windows 下解压后,最常见的问题是换行符从 LF 变成 CRLF。Unix 的 Makefile 一旦有 CRLF,make会报 “missing separator” 错误。处理办法是用sed -i 's/\r$//' Makefile统一清掉。反过来,如果 Windows 下的代码传到 Unix,又可能因为缺少可执行权限导致./configure失败。这个资源里没有 configure 脚本,用普通 Makefile 的话,只需关注换行符。

平台常见编译命令链接库坑点
Linux/BSDgcc -o mj *.c -lpthreadpthread, msocket不需要额外库
Windows (MinGW)gcc -o mj.exe *.c -lws2_32ws2_32需要-lws2_32
Windows (WSL)make同 Linux能直接用 Unix 工具链

如果你用 WSL 在 Windows 下编译,其实就是在 Linux 内核环境里构建,只是文件放在 Windows 的 NTFS 上。此时把源码放到 ext4 文件系统(如~/src而不是/mnt/c)能减少 I/O 延迟。这算是 Windows 下跑 Unix 源码最省事的一条路,我自己经常用 Docker 容器来做交叉验证,注意在容器里跑编译时要把源码目录挂载进去,比如docker run --rm -v $(pwd):/build -w /build gcc make

3. 麻将核心规则:tiles.c 和 scoring.c 的算法拆解

3.1 牌的数据结构怎么设计

tiles.c 文件名的含义很直白——处理砖块。麻将一共 136 张牌(不含花牌),每种牌型有编号。mj-1.6 在 tiles.h 里大概是用enum定义牌型,用宏或数组定义牌张。常见做法是:用一个整数表示牌,0-33 表示一种牌,34 表示无效牌;同一个整数的重复次数就是剩余张数。

typedef unsigned char tile_t; enum { TILE_1WAN = 0, TILE_2WAN, TILE_3WAN, TILE_4WAN, TILE_5WAN, TILE_6WAN, TILE_7WAN, TILE_8WAN, TILE_9WAN, TILE_1TONG, TILE_2TONG, TILE_3TONG, TILE_4TONG, TILE_5TONG, TILE_6TONG, TILE_7TONG, TILE_8TONG, TILE_9TONG, TILE_1TIAO, TILE_2TIAO, TILE_3TIAO, TILE_4TIAO, TILE_5TIAO, TILE_6TIAO, TILE_7TIAO, TILE_8TIAO, TILE_9TIAO, TILE_DONG, TILE_NAN, TILE_XI, TILE_BEI, TILE_ZHONG, TILE_FA, TILE_BAI, TILE_MAX }; int tile_count[34];

这段代码的精妙之处是用 0-33 连续编码牌,这样判断“同一种牌”只需要比较整数,排序也方便。tile_count数组的下标对应牌型,值对应当前剩余的牌数。发牌时从 0-33 里随机挑选,但要注意检查tile_count[type]是否大于 0,否则会出现第 5 张万子。随机洗牌我偏向于用 Fisher-Yates 算法,配合 sysdep 的sys_gettime_ms()做种子,比反复rand() % 34更均匀。

3.2 胡牌判断的暴力递归

麻将要判断是否胡牌,核心是拆解手牌。标准胡牌条件是:除一对将牌外,其余都是顺子或刻子。最简单的实现是深度优先搜索:把牌按顺序排好,从最小的花色开始,尝试三种拆法——当一张刻子、当一张顺子、当将牌。如果某一种拆法能走到底,就返回成功。

int can_hu(int *count, int total) { if (total == 0) return 1; int i; for (i = 0; i < 34; i++) { if (count[i] == 0) continue; /* 尝试拆刻子 */ if (count[i] >= 3) { count[i] -= 3; if (can_hu(count, total - 3)) { count[i] += 3; return 1; } count[i] += 3; } /* 尝试拆顺子 */ if (i % 9 <= 6 && count[i+1] > 0 && count[i+2] > 0) { count[i]--; count[i+1]--; count[i+2]--; if (can_hu(count, total - 3)) { count[i]++; count[i+1]++; count[i+2]++; return 1; } count[i]++; count[i+1]++; count[i+2]++; } /* 尝试拆将牌 */ if (count[i] >= 2) { count[i] -= 2; if (can_hu(count, total - 2)) { count[i] += 2; return 1; } count[i] += 2; } break; /* 从最小的非零牌开始,避免重复搜索 */ } return 0; }

关键在break:每次从最小牌尝试,如果失败就立即结束这一层,因为这时候这张牌无论如何都处理不掉,不用再试其他拆法。这个剪枝能大幅减少递归次数,实战中 14 张牌的判定时间在微秒级。调用前需要先复制一份tile_count,避免破坏原始手牌。如果是十三幺、七对等特殊牌型,需要单独写判定函数,放在 scoring.c 里更合适,因为它涉及额外的番数,不单纯是“能不能胡”。

3.3 计分器的分数累加模型

scoring.c 负责给胡牌后的牌型算番。这个文件和 tiles.c 不同,它操作的是一个“胡牌结果”的结构体,而不是牌桌上的动态状态。在mj-1.6 里,我猜它定义了一个score_result结构,包含门风、圈风、花牌数、有无庄家等字段。计分的核心是“遍历手牌中的每组面子、雀头、特殊牌型,然后累加番数”。

牌型特殊组合番数条件
立直1番门前清且听牌后宣言
断幺九1番所有牌均为中张牌(数值 2-8)
混一色3番包含字牌和同一花色
清一色6番只有同一花色
国士无双役满全部 13 种幺九牌各一张 + 任意一种对子

计分逻辑要用独立模块的原因在于,麻将的番法在不同规则下差异极大。mj-1.6 预设的可能只是日式立直麻将,也可能是国内地方规则,但设计上,把“判断胡牌”和“计算番数”拆成两个文件,方便你用#define切换规则集。我一般会在 scoring.c 顶部加一个RULES_LOCAL宏,选择不同的计分表。

typedef struct { int fan; int fu; int is_yakuman; char name[16]; } score_result; int score_hand(tile_t *hand, int len, score_result *out) { int fan = 0; if (is_guoshi_wushuang(hand, len)) { out->fan = 13; out->is_yakuman = 1; return 0; } /* 这里补充分类判断 */ return -1; }

看上去简单的判断,难度在于组合爆炸:一手牌可能同时满足混一色和断幺九,需要挑选最高番数。如果临时加“一杯口”,还要在顺子拆解时统计“两副相同顺子”。这类代码很容易出现边角漏判,所以建议在测试时把从网上找的番种测试用例作为断言写进scoring_test.c,每次编译后跑一遍,避免改了协议层又碰坏计分。

4. 网络对战与协作流程:protocol.c、controller.c、player.c 怎么配合

4.1 协议帧的编解码

棋牌游戏必须有可靠的通信协议。protocol.c 在源码里负责把结构体转成字节流,以及反向解析。常见做法是定义固定长度的消息头,后面跟可变长类型和序列化的游戏数据。麻将这个场景,只需要几十种消息类型,比如“发牌”“出牌”“碰杠”“胡牌”“聊天”。用一个整数值做类型,跟随一个整数值做长度,再加消息体,就够用了。

typedef struct { int type; int length; unsigned char payload[256]; } protocol_packet; int protocol_encode(int type, const void *data, int len, unsigned char *out) { protocol_packet pkt; pkt.type = htonl(type); pkt.length = htonl(len); memcpy(pkt.payload, data, len); memcpy(out, &pkt, sizeof(pkt)); return (int)sizeof(pkt) - 256 + len; }

这里htonl是为保证字节序一致。Unix 和 Windows 都支持这个函数,但包含头文件不同:Unix 是arpa/inet.h,Windows 是winsock2.h,sysdep.c 里再包一层是对的。protocol_encode的返回值要小心,我这里故意用了固定 256 字节的 payload 来简化问题,实际项目应该用memcpy到缓冲区后返回整个包长度。调用时注意:sizeof(pkt)包含整个数组,所以你不该把整个结构体发出去,而只发送type + length + data部分,否则会带上一堆未初始化的字节。

4.2 controller 的状态机

controller.c 是游戏的“大脑”,它维护一副牌局的状态:当前座位、当前轮到的玩家、牌墙剩余张数、已经打出的牌。它不直接操作 socket,也不画界面,而是接收从 protocol 解析出来的事件,更新内部状态,并触发下一步动作。

typedef enum { ST_WAIT_PLAYER, ST_WAIT_DRAW, ST_WAIT_DISCARD, ST_GAME_OVER } game_state; void controller_on_event(game_state *st, protocol_packet *pkt) { if (*st == ST_WAIT_PLAYER && pkt->type == MSG_READY) { *st = ST_WAIT_DRAW; } else if (*st == ST_WAIT_DRAW && pkt->type == MSG_DRAW) { *st = ST_WAIT_DISCARD; } else if (*st == ST_WAIT_DISCARD && pkt->type == MSG_DISCARD) { *st = ST_WAIT_PLAYER; } }

状态机的价值是让异步网络消息变成有序的流程。棋牌游戏最容易出现的 bug 就是收到重复的“出牌”消息,controller 却没有校验当前状态。所以我在写这类代码时还会加一个last_seq字段,用于丢弃重复消息。在协议里加一个自增序号,比在业务逻辑里加各种 if 要干净得多。

4.3 player 与 greedy AI

player.c 是玩家的抽象,它既可以代表真实人类,也可以代表一个 AI。greedy.c 这个名字暗示了它使用贪心策略:每次只考虑当前局面的局部最优,不搜索未来多步。在麻将这种信息不完全博弈里,贪心反而常见,因为搜索完整博弈树的开销太大。

int greedy_choose(tile_t *hand, int hand_len) { int i; for (i = hand_len - 1; i >= 0; i--) { if (!is_useful(hand[i])) { return hand[i]; } } return hand[hand_len - 1]; }

这段代码的策略很简单:从手牌末尾往前扫,一旦发现一张毫无用处的孤张,就把它打出去。is_useful判断这张牌是否能与手牌形成可期待的搭子。这个 AI 虽然远不如会算宝牌的 AI 强,但作为联机测试的机器人足够了。你可以在 controller 里设置player_type为 0 时走人工输入,为 1 时走 greedy 模块,这样单人测试也能推进牌局。

我在调试协议时,会准备一个--npc 3的命令行参数,让四个玩家全部由 greedy AI 控制,然后让牌局自动跑完 200 局,重点观察协议层是否漏包,以及 controller 的状态是否卡死。这个做法比手动点牌低效,但能覆盖更多边界情况。

5. 在 Unix 和 Windows 下把 mj-1.6 编译跑通

5.1 快速验证编译环境

拿到源码后,先别急着读代码,把可执行文件编出来更重要。Unix 下先确认工具链:which gccwhich cc。如果没有 gcc,在 Debian/Ubuntu 上执行apt-get install build-essential,在 Windows 上如果装了 MinGW,就把gcc.exe所在目录加入 PATH。用下面的命令确认版本。

$ gcc --version | head -n 1

有时候 Windows 命令行的gcc被 Git Bash 的虚拟机命令遮蔽了,我遇到过gcc指向了/usr/bin/gcc(Git 自带),却链接不上 winsock 库。这种情况直接改用 WSL 里的 gcc,或切换到本地 MinGW 的完整路径,例如C:\MinGW\bin\gcc.exe

5.2 实际编译与预期错误

在 Unix 下编译:

$ make clean $ make

如果 Makefile 没写好,就用一条命令手动编:

$ gcc -Wall -O2 -o mj-main gui.c controller.c game.c greedy.c player.c scoring.c sysdep.c protocol.c tiles.c -lpthread -lm

编译完成后,先跑个帮助命令:

$ ./mj-main --help

如果没有任何输出,说明编译成功但入口很安静。常见错误有几种:

error: 'sleep' was not declared in this scope

这是 Unix 下忘加-D_XOPEN_SOURCE或者函数声明被宏屏蔽。在源码开头加#define _XOPEN_SOURCE 600。另一种是 Windows 下链接失败:

undefined reference to `WSAStartup'

这是忘了-lws2_32。把该库补上即可。如果是在 Windows 下用 Visual Studio 编译,需要在“附加依赖项”里填入ws2_32.lib

5.3 双人测试与日志验证

棋牌游戏跑起来后,最需要的验证手段是日志。我建议在 protocol.c 里加一个#define DEBUG_NET 1开关,把每个收到的包打印到 stdout。然后开启两个终端,一个启动服务端,一个启动客户端:

# 终端 1 $ ./mj-main --server --port 9600 --debug # 终端 2 $ ./mj-main --client 127.0.0.1 --port 9600 --debug

如果看到类似recv type=3 len=4的日志,说明协议能通。如果卡住不动,检查防火墙是否允许本机 9600 端口通信。Windows 可能弹窗询问,必须允许;Unix 下则要确认没有禁止 bind 到 9600。再进一步,可以在 service 模式下用--npc 1给其他三个座位塞 AI,让全场自动打牌。日志里看到GAME_OVER出现,就说明一轮完整的牌局流程已经能走通。

最后还有一个实用技巧:如果只想做规则验证,可以关掉 gui,只编译 controller 和 protocol,这样在无显示环境下也能跑测试。这种最小化测法,能帮你在改了几行代码后快速定位是规则出了错还是网络没通。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询