象棋#2 定义动态数据#

本文介绍数据同步的原理,以及如何定义这样的数据。核心思想是, 把数据定义以及直接操作数据的方法都写在基类。

背景知识#

Freekill是一个C/S架构的程序。

服务端负责的是:

  • 同时运行多个房间(这里不关心)

  • 运行房间中的游戏逻辑

  • 通过网络向客户端同步数据、与之交互

客户端负责的是:

  • 连接到服务器、加入房间、显示等待UI(这里不关心)

  • 游戏开始后,显示游戏界面

  • 接收服务端传来的消息,并调用对应的回调函数以同步数据、更新UI等

  • 特定的消息会询问用户做出选择等。客户端此时需要用户进行交互

备注

单机启动的本质上在本机上启动一个服务端,然后连接到本机。游戏逻辑依然运行在“服务端”的那个线程。

从桌游开发者的角度而言,必须弄清服务端和客户端的任务,以及具体代码在哪一端执行的。 就编程语言来说,服务端的代码是完完全全的Lua,客户端则需要Lua处理数据、QML绘制UI界面。

在上面的职责中,我们提到了 同步数据 。这里的数据指的就是游戏途中会动态改变的状态了。 由于游戏逻辑的代码是服务端运行的,因此这个状态肯定是由服务端主动更新,然后服务端再向客户端发送更新信息。 客户端则相对被动的接收服务端发来的信息,以此实现与服务端的数据同步。

一般情况下,服务端都是更改少量数据,并发送一个比较小的TCP报文发给客户端让他做出一样的更改 (也就是所谓的增量同步)。不过在断线重连、旁观这两个场景下,服务端就需要向一无所知的客户端发送整个房间内状态了 (也就是所谓的全量同步)。

总之,我们需要明白这个点: 服务端和客户端存在共享数据 。根据桌游的不同, 实际数据可能很简单,也可能非常复杂。

又如前面所说,桌游需要人员和场地才能进行,放在Freekill中的话,人员就是 Player , 场地则是游戏房间( RoomBase )。如此的人员和房间也分客户端和服务端, 毕竟两个端担任的职责区别很大。一般来说,服务端的人员( ServerPlayer ) 与房间( Room )会提供一些向客户端发送信息的方法, 客户端的人员( ClientPlayer )和房间( Client )则提供与UI界面交互的方法等。

@startuml
hide empty members

abstract class Player
abstract class RoomBase

Player <|-d- ClientPlayer
Player <|-u- ServerPlayer
RoomBase <|-u- Room
RoomBase <|-d- Client

ClientPlayer -r-* Client: n .. 1
ServerPlayer -r-* Room: n .. 1
Player .r.* RoomBase

@enduml

但不论如何,二者都会保存共同的动态数据。从类图可以看出两端有共同的基类,这样的数据就保存在基类之中。

定义动态数据#

说了那么多,终于到了中国象棋的盘面数据定义环节。

中国象棋的盘面状态倒是相当简单:只要有盘面信息就可以了,而且棋子也是固定的32枚。 之前定义棋子时我们给每个棋子分配了数字id,所以用 int board[10][9] 表示盘面足矣~

上面说了数据保存到基类中,因此:

  • 如果有需要保存在房间中的数据,就写在我们自己RoomBase类

  • 如果有需要保存在玩家身上的数据,就写在我们自己的Player类

对于象棋而言,玩家不必保存什么信息,基类 Base.Player 已经提供了座位、身份(红方黑方)等。 我们只需要在 RoomBase 上动土就行了, Player 留空!

行棋至此,我们多了两个自定义的类:

@startuml
hide empty members

abstract class Player
abstract class RoomBase
abstract class "Xiangqi.Player" as MyPlayer #pink
abstract class "Xiangqi.RoomBase" as MyRoomBase #pink

MyPlayer <|-d- ClientPlayer
MyPlayer <|-u- ServerPlayer
MyRoomBase <|-u- Room
MyRoomBase <|-d- Client
MyPlayer -l-|> Player
MyRoomBase -r-|> RoomBase

ClientPlayer -r-* Client: 1 .. n
ServerPlayer -r-* Room: 1 .. n
MyPlayer .r.* MyRoomBase

@enduml

我们拓展了Freekill本身提供的Player和RoomBase两个基类,以放置自定义的数据。

来看代码, xiangqi/core/roombase.lua:

---@class Xiangqi.RoomBase : Base.RoomBase
---@field public board integer[][]
local RoomBase = Fk.Base.RoomBase:subclass("Xiangqi.RoomBase")

local Xq = require "packages.chess-games.xiangqi"
local ReqPlayChess = require "packages.chess-games.xiangqi.core.handler.playchess"

function RoomBase:initialize()
  Fk.Base.RoomBase.initialize(self)

  self.board = Xq:createBoard()
  self:addRequestHandler("Xiangqi.PlayChess", ReqPlayChess)
end

-- ...

return RoomBase

也是给RoomBase加了个字段board,在构造时请出了Engine实例,创建一个全新的棋盘。 ReqPlayChess 又是什么呢?这个得留到客户端那一篇再细说。 我们现在只要知道增加了board数据保存盘面即可。

数据同步#

增量同步#

象棋中唯一的状态是棋盘,唯一的改变状态方式是走子。 显然,服务端和客户端都有必要实现走子操作更改数据的方式,这个更改方式是相同的, 因此将走子的方法写在基类之中。

function RoomBase:movePieceTo(pieceId, fromX, fromY, x, y)
  local id = self.board[fromY][fromX]
  if id ~= pieceId then return end
  self.board[fromY][fromX] = 0
  self.board[y][x] = pieceId
end

为了结合实例说明,我们可以看服务端和客户端对该方法的调用:

-- server/room.lua: 服务端
function Room:movePieceTo(pieceId, fromX, fromY, x, y)
  RoomBase.movePieceTo(self, pieceId, fromX, fromY, x, y)
  self:doBroadcastNotify("Xiangqi.MovePieceTo", { pieceId, fromX, fromY, x, y })
end

-- client/client.lua: 客户端
function Client:handleMovePieceTo(data)
  local pieceId, fromX, fromY, x, y = data[1], data[2], data[3], data[4], data[5]
  self:movePieceTo(pieceId, fromX, fromY, x, y)
end

这段代码中,服务端先调用基类的方法修改数据,再调用 doBoardcastNotify 通知全体玩家 (也就是发通知给所有客户端)盘面发生了变化。客户端收到之后,调用对应的回调方法, 进一步通过基类的方法(直接从基类继承)更新数据。

总之,增量同步的流程是:服务端更新 -> 服务端发信 -> 客户端收到后也更新

一般来说,每种会改变状态的操作都会在RoomBase中设置一个基础的改变数据的方法, 然后在Client和Room中分别按需调用,完成数据的同步。

全量同步#

当用户从掉线状态返回,或者旁观其他棋局时,客户端中只有Engine中的那些静态数据, 而完全没有该房间的动态数据。为了解决这个问题有两种思路:

  • 服务端保存所有走子记录,旁观时一次性发送所有走子,客户端遍历走子记录并从最初一步步更新状态

  • 服务端直接把所有数据发送给客户端

第一种办法有点类似分布式里面的replica log,当新用户加入时,这种办法显然是效率过低的。 这种情况下就通过全量同步的方式将自己的数据发给客户端。客户端拿到完整信息后, 之后的数据同步就正常按照增量同步的方式来。

RoomBase 类(以及 Player 类,象棋只是不需要给Player新的数据而已)中,实现全量同步的核心是两个方法: serialize()deserialize(data) 。前者把相关信息整合成一个可以通过TCP报文传输的格式, 后者则解析收到的信息,并更新相应的字段。

提示

Freekill的数据传输格式为CBOR格式,该格式相比JSON的自由度更高,但一样的千万注意不要把类实例放到里面。 serialize 返回的数据应当只包含基本数据类型(数字、字符串、布尔、nil)以及数组、键值表。

Freekill提供的基类自然囊括了通用的需要同步的信息,我们根据需求自己再往其中添加即可。

function RoomBase:serialize()
  local o = Fk.Base.RoomBase.serialize(self)
  o.board = self.board
  return o
end

function RoomBase:deserialize(o)
  Fk.Base.RoomBase.deserialize(self, o)
  self.board = o.board
end

当用户请求旁观时, serialize 会将当前状态整合成一个Lua表,我们的子类可以添加自定义字段; deserialize 会解析该信息,也是先调用基类方法解析,再赋值自定义字段。

危险

字段命名一定不要和基类产生冲突!否则会覆盖掉那些信息,导致数据同步失灵!

小技巧

Freekill的录像功能也是基于数据同步实现的。录像文件分为完整的可重放录像,以及终盘战况录像。

重放录像的原理是保存客户端收到过的所有来自服务端的信息,重放时从Client的最状态开始,一条一条地重现那些信息,这会进一步触发UI效果等,从而实现录像播放。

终盘战况则是在游戏结束时用 serialize 保存结束时的状态,当用户想要查看时,就从这个状态中恢复所有数据,类似于进行了全量同步。

(TODO:目前并未实现该功能)录像重放过程中可以拖动进度条快速查看不同时间段的状态,这个功能是通过全量同步和增量同步结合实现的。在录像播放途中,程序会每隔一段时间保存该时间点的状态(也就是快照),当用户拖动完进度条后,程序先找到距离该时点最近的一个快照,恢复快照数据,再从快照时间点开始,到拖动进度条时间结束,快速重放这中间记录的录像(也就是增量同步),最后呈现到UI中。

总结#

在这篇文档中,我们首先为自定义桌游的RoomBase增加了自定义数据。 然后我们针对增量同步与全量同步两种数据同步方式,分别在RoomBase中定义了通过操作改变数据的方法, 以及将全部数据进行序列化与反序列化的方法。