相信大家都玩过卡牌游戏 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 是非常老生常谈的东西了,基本上所有的软件工程课都必定会强调。但是真正设计以及实现的时候,把这些做好却并不是一件容易的事情,往往需要经过大量的长时间的思考,才能抽象出好的架构。
我个人喜欢(并且我认为这样比较合理)把模块拆分,直到每个模块只负责一种功能。这么说可能太抽象了,举个例子吧,在这里我假设大家都知道 UNO 的规则:在我最初看的那些 repo 里,游戏的主要逻辑都是放在一个 server(或者 game 之类的)类里面实现的。这里的重点是“一个”。也就是说,当一个玩家出了一张牌之后,现在场上的颜色,数字,玩家轮转的方向,+2 / +4 的惩罚,牌堆的变化等等,都是由一个类来处理的;而且基本上都是在这一个类的一个函数的一个 while loop 里,没有再往下拆分成别的函数了。我当时就觉得,这样这个类要承担的功能太多了,逻辑很多,容易写错,读起来也混乱(当然也可能是我太菜了,一下写不来也看不来这么多逻辑),我不想这样写。
后来我思考了挺久,最后把这些逻辑拆分成三个模块来处理:
然后在这三个模块之上有一个高级的模块,称之为 ActionController,负责协调一盘游戏内这三个模块的工作。ActionController 之上有一个更高级的模块,称之为 Game, 负责一些初始化(比如最开始询问玩家游戏的模式等),还有多盘的游戏的进行(每一盘对应一个 ActionController)。
这样的话我们就可以分开写不同的逻辑了,比如当一个玩家出了一张牌之后,写卡牌进入弃牌堆我们只写 DeckController, 写场上颜色的变化我们就只写 StateController,写方向的变化我们就只写 FlowController,;对于 ActionController, 我们也只要写好核心的逻辑,剩下的细节去调用这三个模块,就好了。这样代码一下子变得好管理了很多。
这里我的心得就是:把所有逻辑拆成多个小逻辑,小模块,以避免在一个模块里写入大量的细节;可以拆分出一些底层的模块,负责具体的逻辑,然后高层模块只需要调用这些底层模块就好了(跟实习的感受很像,我们负责写代码,老板负责管理我们,不管技术细节 2333)
然后提供一些我思考的时候会用到的捷径:
这是设计时的一个思考方向:从底层模块再到高层模块
有 bottom-up 的地方就会有 top-down, 这里也不例外。
刚接手一个项目的时候,像我一样的萌新们往往都会很懵逼,不知道该从何入手,这个时候专注于核心逻辑是一个绝佳的思考角度,就像我们写文章前会先列大纲,而后再一点点填充一样。
我最初构思的时候花了很多时间考虑细节,要划分哪些模块,模块之间什么时候会相互调用,但是收效甚微,始终没有啥好的想法。后来决定换个角度,转而思考 UNO 这个游戏每一局的核心逻辑,最后也是思考了挺久才想出来的。用文字表述就是:
用 pseudocode 表述就是:
game.init()
while not game.over(): # 后来由 FlowController 负责
player = game.next_player() # 后来由 FlowController 负责
if player.can_play() and player.want_to_play():
action = player.get_action()
player.play_action(action)
game.accept_action(action) # 三个 controller 都要负责,由 ActionController 统一调度
else:
game.apply_no_play_logic(player) # 后来由 StateController 和 DeckController 负责, 由 ActionController 统一调度
从这里大家可以看到我最初概括出的核心逻辑,以及这些核心逻辑是怎样演变成底层 controller 和高层 controller 的。这里我的心得就是:最初设计的时候可以不用那么在意细节,先概括出问题的核心逻辑,然后从这个核心逻辑里出发,思考每一步应该由什么模块来负责,这样可以避免漫无目的地乱想。有一点很重要的是:这个核心逻辑是要你反复验证正确的,就是如果每一步执行正确,最终产生的结果也是正确的;也没有没考虑到的情况。所以这个过程也不是一蹴而就,是需要反复地验证与修改的。
也是很老生常谈的方法论。需要注意的是:这里的简单,可以时指不依赖其他部分,不会调用到其他的模块,或者说,即使会调用到,初期实现的时候也完全可以用更简单的东西替代掉。这么说有些抽象,举两个例子,是我实现的时候最早写的两个部分:
接着就可以开始写 Card 和 Player 了,然后就可以写会用到 Card 和 Player 的模块了,以此类推,就像......算法中的 BFS。这里其实 Card 和 Player 看起来是 dependency 最少的模块,其实不然,因为他们作为 class, 有很多可能需要的 attribute 因为我们还没有用到,不知道该怎么定义,如果一开始就写这两个类,可能不如放在之后写好
最后分享一些我觉得好用的细节上的东西,都是和 python 相关的,希望对大家有帮助
说了这么多 bottom-up, top-down 之类的原则,其实个人认为,这些原则都是内化的。我开始做这个项目的时候,也不会想:现在要 bottom-up 了,现在要 top-down 了;而是在经过了一个思考过程之后,回过来看,才发现:“咦?这不就是 Software Engineering 课上学的 bottom-up 吗?这不就是 top-down 吗?” 所以我想,这些原则即使听了无数遍,没有经过实践,也始终只是几个名词而已,只有通过实践才会对这些名词有更深刻的理解,从而内化成自己的思考习惯,自己的一套方法论。程序员的路上没有什么捷径,写代码和思考才是硬道理。
最后附上 Github 地址(目前代码都在 playground/Janzen 里,所以就不贴主目录的链接了;已经有 v1 和重构过后的 v2),欢迎大家加星:https://github.com/JanzenLiu/uno-dev/tree/master/playground/Janzen