UNO游戏实现⼼得(version1)
相信⼤家都玩过卡牌游戏 UNO,由于 FYP (Final Year Project) 的缘故,前段时间⽤ python 撸了⼀个⾮常粗糙的 UNO 出来。有多粗糙呢?⼤概就是没有 UI, 所有操作都靠命令⾏,并且时常⼀瞬间弹出⼀堆⽇志吧。。连我⼀个整天⾯对着命令⾏⾯对各种⽇志的程序员都觉得看得难受。写完初版⼀直想找个时间总结⼀下,最后终于决定在今天把这篇想要写了很久的博客给写出来了,希望能对⼤家有帮助。
(附⼀张游戏截图)
接下来⼤概会总结⼀些我实现的时候⼀些⽐较有感触的地⽅,勉强可以算作是做单⼈(或者很少⼈的)⼩项⽬的⼼得吧。其实都是⼀些⾮常⽼⽣常谈的原则,只是我在实践中有了更深的体会,并且决定把我的思考过程分享出来,给有需要的同学提供⼀些这些抽象的原则对应的⼀些具体的例⼦。
不要怂,就是⼲
最鸡汤,最没有技术含量的⼀条放在最前⾯哈哈哈。其实这⼀条想说的是,对于这样的⼩项⽬,不必太拘泥于别⼈已有的成果,⼤胆尝试⾃⼰重新设计,重新写,不要怀疑⾃⼰做不到。固然每个⼈的经验能⼒有限,但挑战独⽴完成⼀个⼩项⽬(可以少量借鉴别⼈的实现)对于我等萌新来说还是⼀个很必要的历练;做到了当然成就感爆棚,即使做不到,也是能收获不少经验的。下⾯讲⼀讲我从最初在 github 上查找别⼈的实现,到最终决定⾃⼰写的⼼路历程吧。
当时的第⼀步也是在 github 上搜索别⼈的实现,并且最初也是想着在这些代码的基础上改的。但是浏览了⼀下搜索结果⾥靠前的 repo, 发现也不是那么合适。⾸先之所以要写这个 UNO, 是因为 FYP 要做的是强化学习,因此⼀个理想的实现要满⾜两个要求:1)⽅便⾃定义游戏(游戏⼈数,AI 玩家的策略),快速地⾃动化地模拟游戏,还有保存游戏数据;2)⽅便交互:这⾥的交互指的是强化学习中的 agent
可以⼀边进⾏游戏,⼀边更新 policy (还有 value), 然后⼀遍采取新的 action 返回给游戏系统。当时浏
览了⼏个 repo 后发现都不满⾜这些要求:有的可能有好看的 UI, 但是游戏模式(⼈数等)是固定的,或者难以和⼀个强化学习系统对接,等等。于是我就决定⾃⼰写了。这⾥也有⼀个⼼得:项⽬之初要尽可能搞清楚具体的需求,需要写出⼀个怎样的实现,进⽽决定是否沿⽤已有的开源实现,以及后续怎么设计等等。
⾄此我和 partner 也还是打算沿⽤ github 上⼀些 repo 的设计的,但后来经过反复的思考还是决定推倒⾃⼰写。⼀⽅⾯是很想挑战⼀下独⽴完成⼀个⼩项⽬,另⼀⽅⾯也是觉得这些 repo 有些设计得不那么好的地⽅,这个后⾯会再详细讲。
设计:拆分独⽴的模块(Bottom-up)
其实模块化和 bottom-up 是⾮常⽼⽣常谈的东西了,基本上所有的软件⼯程课都必定会强调。但是真正设计以及实现的时候,把这些做好却并不是⼀件容易的事情,往往需要经过⼤量的长时间的思考,才能抽象出好的架构。
我个⼈喜欢(并且我认为这样⽐较合理)把模块拆分,直到每个模块只负责⼀种功能。这么说可能太抽象了,举个例⼦吧,在这⾥我假设⼤家都知道 UNO 的规则:在我最初看的那些 repo ⾥,游戏的主要逻辑都是放在⼀个 rver(或者 game 之类的)类⾥⾯实现的。这⾥的重点是“⼀个”。也就是说,当⼀个玩家出了⼀张牌之后,现在场上的颜⾊,数字,玩家轮转的⽅向,+2 / +4 的惩罚,牌堆的变化
等等,都是由⼀个类来处理的;⽽且基本上都是在这⼀个类的⼀个函数的⼀个 while loop ⾥,没有再往下拆分成别的函数了。我当时就觉得,这样这个类要承担的功能太多了,逻辑很多,容易写错,读起来也混乱(当然也可能是我太菜了,⼀下写不来也看不来这么多逻辑),我不想这样写。
后来我思考了挺久,最后把这些逻辑拆分成三个模块来处理:
皮皮虾歌曲⼀个处理当前颜⾊,数字等卡牌状态的模块,负责接受玩家的 action, 改变状态,暂且称之为 StateController
⼀个处理游戏进程的模块,⽐如改变顺时针/逆时针⽅向,以及检查游戏是否结束,暂且称之为 FlowController
⼀个处理牌堆,给玩家发牌,洗牌等,暂且称之为 DeckController
婴儿木床
然后在这三个模块之上有⼀个⾼级的模块,称之为 ActionController,负责协调⼀盘游戏内这三个模块的⼯作。ActionController 之上有⼀个更⾼级的模块,称之为 Game, 负责⼀些初始化(⽐如最开始询问玩家游戏的模式等),还有多盘的游戏的进⾏(每⼀盘对应⼀个ActionController)。
这样的话我们就可以分开写不同的逻辑了,⽐如当⼀个玩家出了⼀张牌之后,写卡牌进⼊弃牌堆我们只写 DeckController, 写场上颜⾊的变化我们就只写 StateController,写⽅向的变化我们就只写 FlowC
ontroller,;对于 ActionController, 我们也只要写好核⼼的逻辑,剩下的细节去调⽤这三个模块,就好了。这样代码⼀下⼦变得好管理了很多。
这⾥我的⼼得就是:把所有逻辑拆成多个⼩逻辑,⼩模块,以避免在⼀个模块⾥写⼊⼤量的细节;可以拆分出⼀些底层的模块,负责具体的逻辑,然后⾼层模块只需要调⽤这些底层模块就好了(跟实习的感受很像,我们负责写代码,⽼板负责管理我们,不管技术细节 2333)
什么叫股票注册制
然后提供⼀些我思考的时候会⽤到的捷径:
1. 遵循 MVC 原则, 先考虑好有哪些 model , ⽐如这⾥可能是 Card, Player 和 State ,这些模块只管储存数据,⾄于所存的数据什么时
候改变,这些逻辑不⽤管
2. 对应每个 model 构建⼀个底层 controller, 这⾥可能就是 DeckController 对 Card, FlowController 对 Player, StateController 对
State,这些 controller 就负责逻辑,决定在什么条件下,以何种⽅式改变 model 中的数据;当然多数时候 C 和底层 M 都不是严格对应的,只是⼀个构思的捷径
深刻的英文3. 底层 controller 之上再构建⾼层 controller. View 这⾥就暂时不管了,毕竟...这个项⽬中⽬前也还没有做 V, V 也不是这个项⽬的
事故证明模板重点
这是设计时的⼀个思考⽅向:从底层模块再到⾼层模块
广东粤剧粤曲大全
设计:专注核⼼逻辑(Top-down)
关于泰山的诗有 bottom-up 的地⽅就会有 top-down, 这⾥也不例外。
刚接⼿⼀个项⽬的时候,像我⼀样的萌新们往往都会很懵逼,不知道该从何⼊⼿,这个时候专注于核⼼逻辑是⼀个绝佳的思考⾓度,就像我们写⽂章前会先列⼤纲,⽽后再⼀点点填充⼀样。
我最初构思的时候花了很多时间考虑细节,要划分哪些模块,模块之间什么时候会相互调⽤,但是收效甚微,始终没有啥好的想法。后来决定换个⾓度,转⽽思考 UNO 这个游戏每⼀局的核⼼逻辑,最后也是思考了挺久才想出来的。⽤⽂字表述就是:
1. ⾸先初始化;
2. 直到游戏结束,重复⼀下过程:
1. 移动到下⼀个玩家,检查这个玩家是否有可以出的牌,并且是否想出
1. 如果有且想出:询问玩家要出的牌,然后根据这个牌改变游戏的状态
2. 如果没有或者不想出:执⾏摸牌等逻辑
⽤ pudocode 表述就是:
game.init()
while not game.over(): # 后来由 FlowController 负责
player = _player() # 后来由 FlowController 负责
if player.can_play() and player.want_to_play():
action = _action()
player.play_action(action)
流言蜚语什么意思game.accept_action(action) # 三个 controller 都要负责,由 ActionController 统⼀调度
el:
game.apply_no_play_logic(player) # 后来由 StateController 和 DeckController 负责, 由 ActionController 统⼀调度
从这⾥⼤家可以看到我最初概括出的核⼼逻辑,以及这些核⼼逻辑是怎样演变成底层 controller 和⾼层 controller 的。这⾥我的⼼得就是:最初设计的时候可以不⽤那么在意细节,先概括出问题的核⼼逻辑,然后从这个核⼼逻辑⾥出发,思考每⼀步应该由什么模块来负责,这样可以避免漫⽆⽬的地乱想。有⼀点很重要的是:这个核⼼逻辑是要你反复验证正确的,就是如果每⼀步执⾏正确,最终产⽣的结果也是正确的;也没有没考虑到的情况。所以这个过程也不是⼀蹴⽽就,是需要反复地验证与修改的。
实现:从简单的开始
也是很⽼⽣常谈的⽅法论。需要注意的是:这⾥的简单,可以时指不依赖其他部分,不会调⽤到其他的模块,或者说,即使会调⽤到,初期实现的时候也完全可以⽤更简单的东西替代掉。这么说有些抽象,举两个例⼦,是我实现的时候最早写的两个部分:
从抽牌堆抽牌,牌⽤完进⼊弃牌堆:虽说要⽤到 Card, 但是完全可以⽤其他东西代替 Card, ⽐如数字。
这样⼀来就很简单了,分别⽤两个 list 表⽰,⼀个每次 pop 出第⼀个元素 (抽牌),⼀个每次在后⾯ append ⼀个新元素(弃牌)。然后测试⽤数字来代替 Card 就好了。并且写到这,你会开始思考 Card 要怎么定义
玩家轮转:⼀样,虽然要⽤到 Player, 但是⽤数字代替 Player 即可。这样⾸先要维护⼀个头尾相连的双向链表来表⽰玩家的圈圈;然后维护⼀个 boolean 表⽰顺时针与否;然后写⼀个函数可以根据这个 boolean 改变当前的玩家,等等。并且写到这,你会开始思考Player 要怎么定义
接着就可以开始写 Card 和 Player 了,然后就可以写会⽤到 Card 和 Player 的模块了,以此类推,就像......算法中的 BFS。这⾥其实Card 和 Player 看起来是 dependency 最少的模块,其实不然,因为他们作为 class, 有很多可能需要的 attribute 因为我们还没有⽤到,不知道该怎么定义,如果⼀开始就写这两个类,可能不如放在之后写好
⼀些 Tips
最后分享⼀些我觉得好⽤的细节上的东西,都是和 python 相关的,希望对⼤家有帮助
Enum class, 配上 unique 装饰器,定义 CardColor 什么的简直⽅便得不⾏,⽐单纯⽤数字或者字符串好得多
PyCharm: IDE 最爱⽤ JetBrain ⼀家,没有之⼀。我常⽤的功能有:从(变量或函数的)引⽤跳到定义,或者定义跳到引⽤;返回光标上⼀个停留的位置;git 相关功能(从 UI 选择⽂件 commit, 查看历史等);local change 相关功能(强烈推荐这个功能,有时候rm 误删了⽂件怎么办?local history 回滚!);重构;断点调试(python debug 神器!)等。不⽤ PyCharm 我的效率起码减半(捂脸)
asrtion: 常加 asrt 是个好习惯,⼀⽅⾯代码可读性更⾼,另⼀⽅⾯ asrt 配合能推断类型的 IDE,⽐如 PyCharm, 简直是神器,IDE 可以补全⾄少多⼀倍的代码
总结
说了这么多 bottom-up, top-down 之类的原则,其实个⼈认为,这些原则都是内化的。我开始做这个项⽬的时候,也不会想:现在要bottom-up 了,现在要 top-down 了;⽽是在经过了⼀个思考过程之后,回过来看,才发现:“咦?这不就是 Software Engineering 课上学的 bottom-up 吗?这不就是 top-down 吗?” 所以我想,这些原则即使听了⽆数遍,没有经过实践,也始终只是⼏个名词⽽已,只有通过实践才会对这些名词有更深刻的理解,从⽽内化成⾃⼰的思考习惯,⾃⼰的⼀套⽅法论。程序员的路上没有什么捷径,写代码和思考才是硬道理。