前言
虽然我觉得国内桌游的始祖可能国粹麻将什么的, 不过貌似这个词在三国杀风靡之后才被正式使用. 这系列文章中, 我也打算使用三国杀来做样例项目, 分析 B/S 结构的桌游设计方式.按惯例, 首节内容应该是纯吹水而无码的. 这里就先拔一拔目前比较靠谱的三国杀实现.
其一是我很敬佩的一个民间 MOD 版本太阳神三国杀, 武将比官网的全, 还有自制武将, 卡牌, 以及各种趣味度爆表的模式. 不过太阳神三国杀是 C/S 一体的, 与我想要设计的并不很搭边.
另一就是官方的三国杀, 使用 Adobe Flash 制成 (桌面版似乎用的 Adobe Air), 所以也并不能严格算是 B/S 结构. 并且官方版本也不是开源或者开放 API 的, 很难一窥其究竟, 没什么好借鉴的.
移动终端的三国杀就没怎么实测过了, 我本身也不是智能或智障手机爱好者, 对这方面也不是太有兴趣.
读者最好知道三国杀的基本规则, 这样一些具体的例子理解起来会更容易.
B/S 架构的游戏
继续吹一下水. 对于一般的网络应用, 比如 Google Doc 那样的字处理应用, 大部分的逻辑都在浏览器脚本中, 而即使用户去改了浏览器脚本, 导致保存的文档格式有误, 最终受害者也是用户自身. 而多人连线游戏服务器最起码的一点就是要防止用户作弊, 以免破坏游戏状态影响其他玩家, 因此至少服务器端有一份游戏逻辑是必需的.现在的问题是, 客户端 (也就是 Browser 端) 是否也复制一分游戏逻辑呢?
当然可以, 不过假如要重新开发除了浏览器之外, 能够以相同协议与服务器通信的客户端, 或者开放 API, 使得第三方可以自行开发客户端, 那样必然陷入不断重复开发客户端游戏逻辑的无底洞里.
因此, 除了尽可能将全部游戏逻辑堆在服务器端, 服务器端还应该尽可能指导客户端, 例如告知当前游戏状态, 以及客户端该如何响应.
服务器职责
好, 吹水结束, 现在理一下, 服务器端应该- 完善游戏逻辑, 这是必须的
- 游戏开始后, 维护游戏状态
- 接受客户端输入, 返回游戏状态 a
- 接受客户端输入, 返回谁现在应该如何去做什么事情, 这也就是上一部分最后提到的对客户端的指示
- 接受指定客户端输入, 推进游戏进程
协议格式
B/S 模式下传递信息可以考虑传递 JSON. 而 Python 跟 JS 一样, 是不需要指定变量类型的, 如果发现转出来的值有任何问题就直接认为客户端发送了错误数据就行了, 所以服务器端用 Python 可以省不少事.通信内容的具体格式可设计为一个字典, 例如向服务端发送如下信息
{
'token': '10178a67c867b82392f41c9723cf2daae8fd6ab0',
'previous event id': 4,
}
{
'code': 200,
'events': [
{
'type': 'DiscardCards',
'player': 0,
'discard': [
{
'id': 0,
'name': 'slash',
'rank': 1,
'suit': 1,
},
{
'id': 1,
'name': 'dodge',
'rank': 2,
'suit': 2,
},
{
'id': 2,
'name': 'slash',
'rank': 3,
'suit': 1,
},
],
},
{
'type': 'DrawCards',
'player': 1,
'draw': 2,
},
],
}
'code': 200
沿袭 HTTP 返回码, 表示 OK.游戏状态增量与游戏事件
游戏状态的增量就是游戏中的事件, 比如三国杀中的摸牌, 弃牌, 出牌, 发动技能.上面的请求和返回示例的是对服务器请求一次从 event id 为 4 开始, 直到当前时刻的游戏事件列表. 其中的 token 是玩家标识, 在游戏开始分别传给各个玩家, 玩家在请求事件, 提示以及执行动作时需要带上 token. 在客户端需要根据对应的 type 解释这些事件, 然后更新客户端状态, 那么呈现在玩家眼前的就是当前的局面. 事件类型就那么确定的几个, 而客户端就像一台虚拟机一样把这些事件当作指令来执行就好了.
另外, 同一个事件在不同玩家看来是不一样的, 典型是摸牌时, 自己可以看到牌是什么, 而其它玩家只能看到牌的数量. 因此, 获取事件必须带上 token.
服务器提示
服务器提示则是另一番光景. 服务器首先要提示现在是谁在做什么, 接着, 对于那些要做事的客户端, 它们的请求将返回更具体的信息, 该怎么做. 如下面是一次请求提示的返回{
'code': 200,
'players': [0],
'action': 'transfer',
'transfer': [10, 11],
'candidates': [1],
'abort': 'allow',
}
'players': [0]
与 'action': 'transfer'
都是可见的, 表示当前活动玩家的列表 (仅含有玩家 0) 和这些玩家正在做的事情 (转移卡牌). 而后面的 'transfer'
和 'abort'
等项目, 只有活动玩家本身才能知道. 这里它们表示'transfer': [10, 11]
: 表示该玩家可以使用 id 为 10 和 11 的牌来进行'candidates': [1]
: 候选目标列表含有玩家 1- 对于客户端而言, 因为动作是
'action': 'transfer'
卡牌转移, 因此指定的目标只可以是同一个, 如果要分别转移给两名玩家, 得分两次转移. 这是客户端内置逻辑 'abort': 'allow'
: 允许中止这次操作
推进游戏进程
当玩家决定了做什么事情时, 客户端会将玩家的动作序列化为如下的东西扔给服务器{
'token': '10178a67c867b82392f41c9723cf2daae8fd6ab0',
'action': 'transfer',
'transfer': [11],
'target': 1,
}
'action': 'transfer'
转移卡牌'transfer': [11]
卡牌包括 id 为 11 的卡牌'target': 1
目标包括玩家 1
至于服务器为什么知道要利用字典里面这些键值, 以后谈到服务器如何保存游戏状态再讨论, 现在服务器根据游戏状态抓取了这些信息之后开始验证
- 卡牌 id 为 11 的这张卡牌是不是遗计得到的牌, 如果不是则向客户端返回错误, 然后中止验证
- 目标逻辑上是否合理, 比如无法转移给自己 (要留牌应该让
'action'
为'abort'
), 不合理则返回错误并中止 - 以上验证都通过后, 玩家成功转移该牌, 于是游戏状态便向前推进了, 并且产生了一个新的玩家转移卡牌事件
感谢双木成林对本文的审阅和指导, 他是一位热衷于 Python 的上进青年 & geek.