思路见《手写一个日麻游戏》一文。
Roadmap 路线图
v0.1.0-alpha.1雏形
能动:可以输入,处理完输入后有反馈,像个游戏的样子v0.1.0-alpha.2完善基础设施 NOW
能打麻将:可以正常游玩(指获得一局完整的游戏体验)v0.1.0-alpha.3完成人机 AI- 思考 AI 如何介入游戏:com 玩家数据拥有 AI?还是 AI 拥有 com 玩家数据?
- 会打牌
- 会鸣牌
- 会做牌(保留面子打出散牌,鸣牌根据置信度确定)
v0.1.0-alpha.4完成整局单人游戏- 完成听牌提示功能
- 完善主要 UI
- 自由设置思考倒计时
v0.1.0-alpha.5完善日麻规则判定- 完成立直功能
- 完成振听判定
- 完成流局判定
- 完成役种判定 是否允许和牌(自风 / 场风)
- 完成点数计算
- 完成连续多局游戏(半庄 / 东南)
v0.1.0-alpha.6完善人机进阶 AI 策略- 不同流派:役牌 / 立直 / 平和 / 断么九
- 优先打闲风(不考虑大牌)
- 优先打绝张(绝张换听)
- 优先留刻子(保碰争杠)
- 优先打边张
- 优先留宝牌
Changelog 更新日志
v0.1.0-alpha.1a feat: basic ui
v0.1.0-alpha.1b chore: Auto CI/CD & HANDS/DRAWS enum
v0.1.0-alpha.2a feat: basic game frame
- feat: 单元测试 hook
- feat: 优化发牌函数,降低耦合程度:手牌和山牌计算现在最低兼容 2 个玩家
(而且不止可以发 13 张,即使发 7 张,各玩家的手牌和山牌也是正确的。) - feat: 渲染别家手牌信息
- feat: Debug 控制台添加透视功能
- feat: 新增了简单的标题界面和调试界面
- feat: 完成单独的「模拟发牌」调试界面
- feat: 完成单独的「手牌分析」调试界面(目前只有和牌判断,更多功能待补充)
- feat: 完成和牌判定
- feat: 新增了音效(资源来自 天鳳用オリジナル SE: アンコロキング blog)
- style: 更新了图片素材(资源来自 麻雀の画像・素材 - 来夢来人)
- fix: 更新了牌背的渲染方式:着色器染色 → 直接更换素材帧
现在理论上不可能(作弊)看穿牌背(因为它本来就是一张单独 的 背面 牌)。以前取消着色器就可以直接透视牌面了。 - fix: 重绘了 UI 布局,添加了对局信息:东一局 / 本场数 / 点棒数 / 余牌数 / 各家点数
- fix: 现在可以正确渲染赤宝牌了(以前计算是正确的,但显示为普通五万 / 饼 / 索)
v0.1.0-alpha.2b feat: game loop
- feat: 模拟服务端和客户端之间的加密通信
- feat: 完成鸣牌功能(包括加杠 / 暗杠)doing 已完成按钮功能和渲染副露区
- feat: 完成主游戏循环(轮流 turn 摸切)
Reference 借物表
图像(麻将牌)素材:
| 来源 | 原图尺寸 | 格式 | 风格 | 说明 |
|---|---|---|---|---|
| 麻雀素材 [麻雀王国] | 47 × 63 / 33 × 59 | .gif | 拟物 | 包含点棒 / 骰子 V1 已弃用 |
| 麻雀の画像・素材 - 来夢来人 | 80 × 112 / 51 × 72 | .gif | 扁平 | V2 已弃用 |
430 × 600 👍 | .svg | 拟物 | 包含点棒 V3 当前使用中 | |
| Mahjong Icons - Martin Persson | 128 × 128 / 64 × 64 | .png | 拟物 | 包含花牌 / 季节牌 考虑替换 |
音效素材:
| 来源 | 原格式 | 说明 |
|---|---|---|
| 天鳳用オリジナル SE: アンコロキング blog | .wav | 包含打牌 / 立直 / 吃 / 碰 / 杠 / 荣和 / 自摸 |
Review 反思
2022 年 4 月 23 日:各家的舍张记录究竟属于谁
静下来思考的时候发现两个问题。
其一,这个问题是我在思考更好的 AI 引入方式的时候发现的:
各家进张(摸牌)和舍张(打牌)的历史记录,不属于玩家,而是属于这局游戏。没错,连进张记录也属于游戏;和舍张记录的区别也就只对创造者可见,对其他玩家隐藏罢了。
我本来在想引入 AI 后,是 AI 对象拥有一个完整的玩家数据对象?(AI drive 人机玩家)
还是玩家数据对象内部嵌入了一个 AI 对象?(AI in 人机玩家)
此时我突然意识到,「每家打了哪些牌」这个信息,并不属于玩家自己,而是整局游戏。
参与游戏的每个玩家(甚至包括没有参与的旁观者)都能获取这些信息。
此前我将进张 / 舍张记录放在玩家对象里是非常愚蠢的做法。
其二、我陷入了一个「需要帮助、却下意识拒绝帮助」的思维怪圈。
不得不说语言实在是很神奇的东西,有时候微不足道,有时候又雷霆万钧。
甚至不需要说出口(不过说出口的心理增幅作用也很重要),在心里化作成型的语句就够了。
本来我就是随便起了个标题——「手写」。我以为自己没有当真,其实潜意识里当真了。
心里埋下这个种子之后,它在不知不觉中长成了一种执念。
明明需要进修,却无意中拒绝进修,而是一味钻牛角尖自己闭门造车。
他妈的,动动手指搜索一下解决方案,参考而已,又没让你照抄,真的有那么难吗?
明明有成熟的算法可以参考,非要自己(宁愿避开)从零开始实现,图个啥呢。
2022 年 4 月 27 日:第一次重构
稍微回头看了下,写得太随意了,根本没有任何组织性,想到哪里写到哪里。
Roadmap 既没有提纲挈领,也基本没有前瞻性,完全就是流水帐。重写重写。
重新设计项目架构:
- 麻将通用基类
MahjongBase:麻将相关基础功能- 生成
new_tile_walls()使用公平洗牌算法(打乱顺序)洗好的牌堆 - 解析字符串牌面 序列化与反序列化
serialize()/deserialize()以便 md5 验证 - 根据牌序按人数和游戏方式发放手牌
deal_tiles()并在此基础上计算出山牌 - 根据牌序和开杠数量
get_dora()计算对应的宝牌指示 / 里宝 / 岭上 / 海底 - 判断一副手牌
can_win()是否能和 - 判断一副手牌
is_ready()是否已经听牌 - 计算一副手牌
get_shanten()的向听数 - 评估一副手牌
estimate_hand_value()的得分(符数、翻数、役种、基本点数)
- 生成
- 麻将游戏类
MahjongGame:麻将游戏所需功能- 生成(记录)牌谱
- 麻将游戏 Server 服务端
BoardHost:游戏主持人,负责裁定
(联机则把这部分放到云端)-
读写牌谱 :手牌、山牌、宝牌、余牌、荒牌流局、摸切记录、鸣牌记录 - 记录
对局信息 :场风、本场数、点棒(立直)数、座位(自风)、点数
-
- 麻将游戏 Client 客户端
PlaySpace:用户接口(UI)提供交互- 读取已知牌谱,并进行渲染
- 接受用户输入(玩家操作)
- 排序手牌
- 听牌提示
2022 年 4 月 27 日:玩家对象是一组数据还是一个实例
玩家根本就不该是一个类。
玩家不是一个实例,不该有自己的方法,它就是一组数据,高度模式化的数据。
Player 顶多有 seat / hand / melds / discarded 四个属性,没了。
它不会有摸牌,打牌,鸣牌等「行为」。我之前做了行为不说,甚至还把「进张记录」保存在玩家类里,美其名曰「面向对象」,简直离谱。
本意是用四个数组就把牌谱记得明明白白,出发点是好的,可这反而限制了我的发挥。
写一个牌谱类用来专门记录牌谱,有那么麻烦吗?
你要是真的有那么追求极简,你咋不用机器码编程呢?
这就是提前写一遍原型带来的坏处了,确实提前趟平了许多坑,但同时也带来了思维定势。
另外我主观上也有锅,以前没怎么写过游戏,导致写起来很多时候单一反应还是面向过程的思路,而不是纯粹的面向对象。
这一点想通全都通了。
- 服务端开房间,玩家连接进来,开始游戏。
- 服务端生成配牌,md5 通知客户端。开局,把起始手牌发给玩家,没了。
- 开始正常游戏循环。所谓正常游戏循环,就是服务端处理完,问客户端要输入,
yield,等到客户端响应输入或者超时判定为掉线,继续处理,下一个循环。 - 每次有更新(摸牌,打牌,鸣牌)把更新的数据发给客户端,作为通知。
- 所有判断都归服务端,客户端只负责渲染接收到的数据,以及在需要时询问用户输入。
- 服务端需要输入,就发请求给客户端,客户端限时回应,过时不候。
- 打个麻将而已,所谓的通信就是这样了,能有多复杂?
打个麻将还考虑把运算量放在客户端来优化是不是想太多?
我可能对 2022 年的计算机算力有一些误解。
2022 年 4 月 29 日:第二次重构
做技术验证的时候老毛病犯了:沉迷造轮子。
写得时候开心得不行,完全是不顾一切,直接沉溺进去了。
做完了完整的三次握手通信 + AES 加密解密数据包,然后发现 Godot 有封装好的现成实现。
心态当时就崩了,甚至起了弃坑的念头。
冷静下来反思,这他妈不是你自己的问题吗,假装做了架构设计,其实有个锤子设计。
就是东一榔头西一棒槌;想到哪里 —— 最多写下来记录一下 —— 然后就兴冲冲地去做,跟没有设计有啥区别。
重画了 UML 类图,图太大这里放不下,开了个 单页。
服务端不需要把鸣牌判断发给玩家,客户端拿到摸上来的牌可以自己判断。
我之前是说过没必要把服务端的运算量分担到客户端上,但这也没分担啊。
只是重复运算了两次,以减少通信传输的数据包次数和大小。
这个功能主要是为了证明「游戏内部没有篡改数据」:每局的配牌在一开始就确定了(洗好牌)。
使用的 md5 算法理论不可逆,也就防止了根据 hash 后的 md5 码反向破解出配牌顺序。当然你硬要遍历一个彩虹表出来逆向谁也拦不住你是吧。
彩虹表大小: $ \LARGE{\frac{136!}{4!^{34}}} \approx 4.327 \times 10^{185} $ (参考)