翻译自 Redux Hero 系列文章第 4 篇,原文链接请戳我。
当你想到像 《勇者斗恶龙》(Dragon Warrior) 或 《最终幻想》(Final Fantasy) 这样经典的 RPG 游戏时,你就会发现这些类型的游戏内容是在一张大地图上面四处游荡然后与遇到的怪物展开战斗。
但是为了让英雄轻松些,英雄是不会在每一次前进操作的时候都会碰到大怪兽的,大怪兽会随机地分布在大地图上的某个角落但不会出现在所有角落(否则英雄会忙不过来的)。所以这里的关键在于,随机性(Randomness)。
但是在 Redux 的世界里,所有的东西都应该是纯函数(确定性和无副作用),所以随机性在哪里呢?
然后这里还有一个更大的问题:我们连贯的应用逻辑(主角出生,闲逛地图,打怪兽,被怪兽打,扣血,主角领盒饭)一般应该放在哪里?(where does my application logic belong in general) 似乎我们更通常的做法是把这些连贯的应用逻辑分散到不同的角落里(逛地图的操作属于一个 redux 模块文件,主角状态和血量属于另外一个 redux 模块文件)。
幸运的是,这时候 redux-saga 出现了,提供了一种有效的解决方案。
首先,为我们这个英雄打怪兽领便当的过程定义伪代码:
loop while player is still alive
wait for player to move
are we in a safe place?
randomly decide if there is a monster
fight the monster
end loop
复制代码
当主角还活着
让我们来完善我们的第一行代码:
export function* gameSaga() {
// 一直循环,只要主角血槽还没空
let playerAlive = true;
while (playerAlive) {
// 活着的时候,你能做很多事情。
// 所以要珍惜活着的每一份每一秒。
}
}
复制代码
redux-saga 依赖 生成器(generator),我们这里不会对生成器进行详细介绍(因为我们的故事是英雄打怪兽)。想了解关于生成器的更多东西,请戳 function on MDN 和 ES6 Generators by David Walsh 。
等待主角移动
然后我们来为主角移动创造一些 action 吧:我们会有一个 MOVE 的 action 类型和一个 move() 的 action 类型构造函数。我们键盘上的一些按键会派发这些 types 。
const Actions = {
MOVE: 'MOVE',
// ...
}
const move = ({ x, y }) => ({
type: Actions.MOVE,
payload: { x, y }
})
复制代码
一旦 dispatch ,action 就会被中间件拦住(这里我们的 redux-saga 就是其中之一的中间件)。这时候 redux-saga 把 action 壁咚了之后,就可以 猥琐欲为 了。
export function* gameSaga() {
let playerAlive = true;
while (playerAlive) {
// 等待主角移动
yield take(Actions.MOVE)
// 只有当主角移动了才会进入到下一行来
}
}
复制代码
take 会阻塞 saga ,直到指定的 action 被 dispatch。但是注意,这里的阻塞不会阻塞 ui 或页面操作,一个 saga 其实很像一个后台自动运行着的进程。
主角是否安全(没碰到怪兽)
我们不希望在有公主的城堡房间里都会碰到怪兽忙着打架,这样主线故事就不浪漫了。
export function* gameSaga() {
let playerAlive = true;
while (playerAlive) {
yield take(Actions.MOVE);
// 主角是否安全
const location = yield select(getLocation);
if (location.safe) continue;
}
}
复制代码
select 允许我们从 store 中拿取 state。这里的 getLocation 是一个选择器,接收 state 作为它的参数:
export const getLocation = state => {
const { x, y } = state.hero.position;
return worldMap[ y, x ];
}
复制代码
redux-saga 代替我们访问 store ,并用 getState 获取状态传递给我们的选择器(selector)。
随机决定某个位置是否有怪兽
我们不能把 Math.random() 的调用放到 reducer 里,因为 Redux 三大原则 之一是函数必须是纯的(没有副作用,禁止直接修改应用状态,而是返回一个新的状态)(只依赖于输入参数,同样的参数永远返回相同的结果,不管何时何地调用这个纯函数)。显然 Math.random() 是不纯的。
我们先来看一个坏的例子:
export const reducer = (state = {}, action) => {
switch (action.type) {
case Action.MOVE:
const monsterProbability = Math.random(); // BAD!!
if (monsterProbability > location.encounterThreshold) {
// 我们的主角遇到了一只怪兽
}
return newState;
}
}
复制代码
上面那个是坏例子,然而我们却可以在 saga 里面这样干:
export function* gameSaga() {
let playerAlive = true;
while (playerAlive) {
yield take(Actions.MOVE);
const location = yield select(getLocation);
if (location.safe) continue;
// 随机决定是否会遇到怪兽
const monsterProbability = yield call(Math.random);
if (monsterProbability < location.encounterThreshold) continue;
// 我们的主角在这里遇到了一只怪兽
}
}
复制代码
因为我们的 random 不是直接在 redux 内部使用的,所以并不会破坏 redux 的原则和纯函数性质。
打怪兽 !!
因为战斗的过程会比较复杂,所以我们创建另外的单独的 saga 来处理战斗过程。fightSaga 最终会返回一个布尔值(如果主角活下来了就返回 true ,否则返回 false):
export function* gameSaga() {
let playerAlive = true;
while (playerAlive) {
yield take(Actions.MOVE);
const location = yield select(getLocation);
if (location.safe) continue;
const monsterProbability = yield (Math.random);
if (monsterProbability < location.encounterThreshold) continue;
// 打怪兽
playerAlive = yield call(fightSaga);
}
}
复制代码
下面给出 fightSaga 的伪代码实现:
begin loop
monster's turn to attack
is player dead? return false
player fight options
is monster dead? return true
end loop
复制代码
然后下面就是正式的 JavaScript 实现:
export function* fightSaga() {
const monster = yield select(getMonster);
while (true) {
// 怪兽发起攻击 !!
yield call(monsterAttackSaga, monster);
// 主角死了没死 ??
const playerHealth = yield select(getHealth);
if (playerHealth <= 0) return false;
// 主角发起攻击 !!
yield call(playerFightOptionsSaga);
// 怪兽死了没死 ??
const monsterHealth = yield select(getMonsterHealth);
if (monsterHealth <= 0) return true;
}
}
复制代码
防止 saga 函数代码量过大时很重要的,我们可以把上面所有我们还没有实现的 saga 全部内联写到 fightSaga 里面,但是这样会使可阅读性下降,并且细分 saga 也更便于以后测试。所以这里 fightSaga 只负责声明顺序,怪兽攻击然后主角攻击,直到最后怪兽没了或者主角领便当了。
接下来我们来实现怪兽攻击函数 monsterAttackSaga 和主角攻击函数 playerFightOptionsSaga 。
轮到怪兽攻击了
让我们思考一下游戏体验。对于玩家来说,如果怪兽在主角进行 move 移动操作后马上对他执行攻击(每走一步都会马上被挨揍,管你走没走出攻击区域),这会对玩家造成巨大的心灵创伤。所以我们会加入延迟,在主角移动之后的一段时间内,怪兽不会瞬间攻击我们的主角。
export function* monsterAttackSaga(monster) {
// 等待一小段时间延迟
yield call(delay, 1000);
// 随机产生伤害数值
let damage = monster.strength;
const critProbablity = yield call(Math.random);
if (critProbability >= monster.critThreshold) damage *= 2;
// 华丽丽的攻击前预备动作
yield put(animateMonsterAttack(damage));
yield call(delay, 1000);
// 攻击 !!
yield put(takeDamage(damage));
}
复制代码
put是 redux-saga 用来*派发 action(dispatch action)*的。animateMonsterAttack() 会返回 { type:.. , payload: .. } 的 action 对象。
主角战斗函数
主角的战斗函数要比怪兽复杂些,因为主角不单单只是进行攻击,他是主角他还可以做其他事情(比如中途吻一下公主或者露出悲伤抑郁的神情,或者正常一点的就是喝药补血和放大招)。
wait for player to select an action
if attack, run the attack sequence
if potion, run the heal sequence
if run away, run the escape sequence
复制代码
export function* playerFightOptionsSaga() {
// 等待玩家选择一个动作执行
const { attack, heal, escape } = yield race({
attack: take(Actions.ATTACK),
heal: take(Actions.DRINK_POTION),
escape: take(Actions.RUN_AWAY)
});
if (attack) yield call(playerAttackSaga);
if (heal) yield call(playerHealSaga);
if (escape) yield call(playerEscapeSaga);
}
复制代码
我们可以往 race 里面放任何东西 —— 甚至是执行另外一个 saga 函数 —— race 会取第一个执行完毕的函数结果返回,其他未执行完成的函数就会被取消掉。下面我们演示如何使用 race 来使玩家读存档:
export function* metaSaga() {
// 等待静态资源(assets)加载
// 展示片头动画
// 等待玩家点击开始游戏
// 开始游戏的同时,监听玩家读取存档操作
while (true) {
yield race({
play: call(gameSaga),
load: take(Actions.LOAD_GAME)
})
}
}
复制代码
LOAD_GAME action 会将 state 还原成初始状态 initialState,然后 saga 就会拦截到 LOAD_GAME 事件。从而使 load 执行完毕(resolve)而使得 play 执行中断(reject)(可以把 saga 的 race 想象成 Promise.race)。最终 redux-saga 就会中断掉游戏 gameSaga 的进行。
概述总结
Effect(作用) | Purpose(目的) |
---|---|
take | to wait for an aciton(等待一个 action 动作发生) |
select | to access state(获取一个 state 状态) |
call | to call a function or another saga(执行一个函数或一个 saga) |
delay | to delay execution(延时执行) |
put | to dispatch an action(派发 action) |
race | to wait for the first completion from a set of effects(等待第一个执行完成的函数结果返回,然后取消掉其余的函数执行) |
了解更多 saga 的 api 请戳 Redux-Saga documentation。
此系列其他文章
- Part 1: 英雄诞生(一种有趣的方式介绍 redux)
- Part 2: actions 都干了些什么(一种有趣的方式介绍 redux-actions)
- Part 3: 做出明智的选择(一种有趣的方式介绍 reselect)
- Part 4: 每个英雄都需要一个大反派(一种有趣的方式介绍 redux-saga)
- Part 5: 一个英雄应该是可测试的(一种有趣的方式介绍 redux-saga-test-plan)