-
Notifications
You must be signed in to change notification settings - Fork 1
Motivation
Stateful Game 的 SDK 本身很简单,复杂的是思路与概念。下面会分成概念与用法两个角度来说明 Stateful Game 要解决的问题。
Client Engine 回答了「如何把游戏逻辑运行在服务端」这个问题,其 SDK 提供了一个 Game 的概念来抽象一局游戏,这个 Game
主要关注的还是其本身的生命周期与玩家管理上,并不涉及具体的游戏逻辑(尽管提供了一些简化消息处理与转发的 helper 方法但并不是一个系统的方案,Demo 的客户端 也只是简单的将收到的消息打印出来)。
Client Engine Stateful Game 就是要回答「如何实现这些游戏逻辑」这个问题。
为了解决这个问题,Stateful Game 引入了一些概念,也复用了一些现成的概念与模式。但这些概念是从最朴素的 (Client Engine 的)Game 出发,为了解决问题一步步引入的。
eventHandler = ({ getState, setState }) => { /** game logic */ }
核心的思想很简单,服务端维护一份游戏的状态(State)作为 the single source of truth,State 单向的往客户端(目前是全量)同步。State 是由事件(Event)驱动的,服务端会响应不同的 Event 来改变 State(这些响应 Event 改变 State 的方法我们称之为 EventHandler)。Event 可能是客户端产生的(比如用户操作),也可能是服务端产生的(比如一个倒计时结束了)。
以上,我们把游戏逻辑抽象为:
- 游戏初始状态(initialState)
- 一组事件(Event)
- 一组描述如何根据 Event 改变 State 的 EventHandler
有个上面的抽象,游戏其实已经能运行起来了。但有一些常见需求的还不能满足。
filter = (state, player) => clientState
第一个需求是 secret State。现在所有的客户端看到的都是与服务端一致的完整的状态,很多游戏都会需要让客户端看到一个被「过滤」过的状态(比如不能看到对方手牌的值)。为此我们引入一个 Filter 方法来描述如何过滤完整的状态得到玩家客户端状态。
higherOrderEventHandler = (eventHandler) => enhancedEventHandler
(严格来讲高阶函数这个概念本身不解决任何问题,只是 SDK 提供了两个高阶函数来解决下面的问题,这里就以这个为标题了。)
第二个需求是限制某些 EventHandler 只能由服务端发起(你肯定不希望客户端发起的「宣布某玩家胜利」的事件也有效吧)。这个需求可以通过在 EventHandler 一开始判断 event 的发起环境的来过滤掉客户端发起的事件。把这部分逻辑提炼出来就是一个高阶函数: fromServerOnly。这个方法接收一个 EventHandler 作为参数,返回一个新的但不会响应客户端发起的事件的 EventHandler。
第三个需求是即时反馈。目前主要的游戏逻辑部分(EventHandler)都是在服务端运行的,客户端用户的所有操作都要等到服务端运行 EventHandler,再将最新 State 同步回来之后才有反馈,这不是良好的体验。考虑到客户端总是会以收到服务端的最新 State 为准,我们可以简单的将一部分 EventHandler 同时在客户端运行(如果客户端也用 JS 的话还可以直接复用代码)。但在这个过程中我们还会遇到一些问题:
- 因为 EventHandler 的逻辑中可以继续派发 Event,如果 EventHandler A 中派发了 Event B,由于 EventHandler A 同时在客户端与服务端执行,EventHandler B 就会在服务端被执行两次。这个问题可以通过给 EventHandler B 包一层
fromServerOnly来解决。 - 因为 Filter 机制的存在,客户端的 State 可能与服务端有不同的类型甚至结构,因此在实现 EventHandler 的时候需要考虑到这一点。好在脚手架项目使用了 TypeScript,可以在很大程度上保证所有情况被正确处理。但是还有一种 case 是当前客户端的 State 不完整导致某些 EventHandler 根本无法运行(比如因为牌堆是 secret 的,客户端无法进行「从牌堆顶部抽一张牌」的操作),因此需要限制这些 EventHandler 只在服务端运行,类似
fromServerOnly,我们提炼出一个高阶函数serverOnly。(除了上面说到的 case,其实还有很多时候我们会需要限制 EventHandler 只在服务端运行,例如客户端争抢一个锁的场景,或者逻辑中涉及到随机数的情况。)
到这里我们再来回顾一下,一个游戏被我们抽象成了:
- initialState
- 一组 Event
- 一组描述如何根据 Event 改变 State 的 EventHandler
- 一个描述如何过滤完整的状态得到玩家客户端状态的 Filter
我们可以使用这些「配置」来「定义」一个游戏,此时我们有了第一组 API:
- 服务端的
defineGame方法,接受initialState: State,events: EventHandlers,filter: Filter三个参数,返回一个 Game 类(继承 Client Engine 的 Game)。开发者可以通过这个 Game 类在服务端emitEvent。 - 客户端的
createGameClient方法,接受initialState: State,events: EventHandlers,client: PlayClient三个参数,返回一个 GameClient 实例。开发者可以通过 GameClientgetState、订阅ClientEvent.STATE_UPDATE事件以及在客户端emitEvent。
reducer = (currentState, action) => nextState
如果你需要实现一个简单如剪刀石头布的游戏,上面的抽象已经够了。但是随着游戏的逻辑变得复杂,游戏的 State 会变成一个又宽又深的状态树,此时在 EventHandler 中改变 State 时会需要与整个 State 打交道(其中绝大部分都是这次操作不关心的),这种操作会变得非常的繁琐且容易出错。并且因为不知道有哪些 EventHandler 会操作某部分状态(子树),这让对一部分状态的结构进行调整变得非常困难。
我们需要一种可控的描述复杂状态与状态变化的机制。
好在「复杂状态管理」在前端应用开发领域已经有了很多成熟的方案,我们选了生态最完善的的 Redux。
Redux 将「Event -(EventHandler)-> 改变 State」拆成了两步「Event -(EventHandler)-> 派发 Action -(Reducer)-> 改变 State」。那么 Redux 是怎么解决复杂状态管理的问题的呢?
- 引入了一些限制。
- 不允许直接操作 State。在原先 EventHandler 中,我们通过 setSate 方法来直接操作 State 树,而在 Redux 模式下这是不允许的。新的流程引入了两个概念,Action 与 Reducer。Action 描述「发生了什么」,是驱动 State 变化的唯一信息来源。Reducer 描述了 State 如何根据不同的 Action 改变。我们必须通过派发 Action 的方式来改变 State。
- 另一个限制是 Reduce 必须是 pure 的,这样所有的状态变化都是可预测的(也意味着可测试、可重放)。
- Reducer 的是可以组合的,一个复杂的 Reducer(对应那个复杂的全局 State)可以拆成多个简单的独立的 Reducer(对应多个 sub-State) 的组合。参考:https://redux.js.org/basics/reducers#splitting-reducers
如果没有用过 Redux,上面的解释可能太抽象,可以移步 其文档 获取详细介绍与示例代码。
回到我们的游戏,我们的抽象发生了一些变化:
- * initialState
* 一组 Event
+ * 一组 Action
- * 一组描述如何根据 Event 改变 State 的 EventHandler
+ * 一组描述如何根据 Event 派发 Action 的 EventHandler
+ * 一组描述如何根据 Action 改变 State 的 Reducer
* 一个描述如何过滤完整的状态得到玩家客户端状态的 Filter其中 initialState 可以通过 Reducer 计算出来因此不再需要单独列出了。
此时我们有了第二组 API:
- 服务端的
defineReduxGame方法,接受events: EventHandlers,reducer: Reducer,filter: Filter三个参数,返回一个 ReduxGame 类(继承 Client Engine 的 Game)。开发者可以通过这个 ReduxGame 类在服务端emitEvent,或者dispatch(action)。 - 客户端的
createReduxGameClient方法,接受events: EventHandlers,reducer: Reducer,client: PlayClient三个参数,返回一个 ReduxGameClient 实例。开发者可以通过 ReduxGameClientgetState、订阅ClientEvent.STATE_UPDATE事件以及在客户端emitEvent。
总的来讲,Event/Handler 是 游戏视角 的,关注点侧重于客户端 - 服务端交互部分,Action/Reducer 是 状态视角,更关注状态本身。
尽管都是描述「发生了什么」,Event 描述的是「游戏里发生了什么」,你可以在 EventHandler 的 context 中拿到与游戏相关的信息(比如谁发起的、玩家目前的状态等)。Action 描述的是「状态发生了什么」,Reducer 里只有 currentState 与 action 两个参数。当然,在有些情况下这两个概念可能没有那么泾渭分明,可能会出现 someEventHandler = () => dispatch(actionWithTheSameName) 这样的 EventHandler,这是正常的(或者也许你并不需要 Redux)。
上文提到,Reducer 必须是为 pure 的,也就是说这个函数不能有副作用,这就意味着不能使用 currentState 之外的其他状态、不能 call SDK 的 API、不能有异步逻辑、不能有 Math.random。EventHandler 没有这个限制(是的,这些有副作用的逻辑都应该在 EventHandler 里)。
可能不需要。
ReduxGame 要解决的是「State 结构复杂」导致难以维护的问题。我们建议先使用 State, Event, EventHandler 的模式来抽象并实现游戏,如果发现 State 的结构复杂(出现了很多相对独立的状态或者嵌套的状态),代码中产生了很多冗余。这时候再引入 Redux 也不迟。
另一方面,ReduxGame 主要关注「State 结构 复杂」的问题,它并不关心某个具体的状态如何变化。如果游戏的 「State 转移逻辑 复杂」(某个状态可能的值多,不同值之间转移的规则复杂,这种情况在游戏中也很常见),你需要的方案是用有限状态机来描述游戏状态(Redux 可以看作无限状态机)。我们接下来会介绍结合 XState 来解决这个问题。