ReactRPG 代码阅读之 Redux 数据层设计

2019-03-19

前言

ReactRPG 是一个用 React 开发的地牢探险游戏,这是一份很好的 React、Redux 学习资源。在本文中我们学习它的 Redux 数据结构实现。通过游戏学习 Redux,是很好玩的体验。

如果你不知道 ReactRPG 是什么,可以先参见之前的文章使用 React+Redux 编写 RPG 游戏(翻译)

需要注意的是,读者最好具备一定的 Redux 基础。

项目概览

代码位于工程的 src 目录下,我们先看下这个目录下的构成:

src
├── App.js                      // 主应用
├── __tests__                   // 测试文件
├── components                  // React 组件
├── config
│   ├── constants.js            // 常量定义
│   └── store.js                // store 定义,这是我们本篇分析的关键
├── data                        // 游戏数据
├── features                    // 游戏行为定义(actions,reducers)
├── index.js                    // 应用入口
├── index.scss
└── utils                       // 工具类

其中:

  • 项目的模块划分比较清晰,值得学习

  • 在本系列中,我们先看数据层,后看 UI 层,因此 config、data、features 这三个目录是我们感兴趣的

src/config/store.js

在这个类中定义了 Redux Store。

各个 reducer 按照功能被拆分到不同的模块当中去:

import appState  from '../features/app-state/reducer';
import player    from '../features/player/reducer';
import dialog    from '../features/dialog-manager/reducer';
import map       from '../features/map/reducer';
import gameMenu  from '../features/game-menus/reducer';
import world     from '../features/world/reducer';
import stats     from '../features/stats/reducer';
import inventory from '../features/inventory/reducer';
import monsters  from '../features/monsters/reducer';
import snackbar  from '../features/snackbar/reducer';

通过 combineReducers 将它们组装起来:

const rootReducer = combineReducers({
  appState,
  player,
  dialog,
  gameMenu,
  map,
  world,
  stats,
  inventory,
  monsters,
  snackbar
});

作者使用 redux-persist 来做状态持久化。它不在文章的主题中,我们将它略过。

最后是创建 store,并将它导出:

const store = createStore(
  persistedReducer,
  compose(
    applyMiddleware(thunk),
    // this mixed operated is needed, otherwise you get a weird error from redux about applying funcs
    // eslint-disable-next-line
    process.env.NODE_ENV === 'development' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__() || compose
  )
);

export default store;

其中:

  • 作者还添加了一个中间件 redux-thunk

Reducers

作者对 Reducers 的拆分非常清晰,值得借鉴:

features
├── app-state
│   ├── actions
│   └── reducer.js
├── dialog-manager
│   ├── actions
│   ├── dialogs
│   ├── index.js
│   └── reducer.js
├── game-menus
│   ├── actions
│   ├── game-music
│   ├── game-settings
│   ├── index.js
│   ├── reducer.js
│   └── styles.scss
├── inventory
│   ├── actions
│   ├── index.js
│   ├── reducer.js
│   └── styles.scss
├── map
│   ├── index.js
│   ├── map-padding.js
│   ├── map-tile.js
│   ├── random-map-gen
│   ├── reducer.js
│   └── styles.scss
……

其中:

  • features 下按照功能进行拆分:比如物品栏(inventory)、地图(map)
  • 在每个子模块下:
    • reducer.js 盛放 Redux redux
    • actions 是一个目录,定义了相关的 Redux actions

actions 目录下,每个 action 单独一个文件:

inventory
├── actions
│   ├── buy-item.js
│   ├── consume-potion.js
│   ├── drop-item.js
│   ├── equip-item.js
│   ├── load-starting-items.js
│   ├── pickup-item.js
│   ├── sell-item.js
│   └── unequip-item.js
├── index.js
├── reducer.js
└── styles.scss

features/inventory

了解数据结构就是了解 Redux Reducer,也就是 features 下的这些模块。

我从中选择了一个比较好入手的,即 inventory。

初始状态

我们先来看下 inventory 的初始状态(位于 reducer.js):

const initialState = {
  items: [],
  maxItems: MAX_ITEMS
};

其中:

  • items 是一个数组,表示玩家所持有的物品
  • maxItems:表示玩家持有物品数量的上限,这个值是 8

通过初始状态,我们可以得知物品栏的数据结构是什么样。

捡装备

玩 Rogue-like 游戏,捡装备是最爽的事情了。我们来看如何用 Redux 实现捡装备。

它的代码如下:

export default function pickupItem() {
  return (dispatch, getState) => {

    const { inventory, dialog } = getState();   // 获取老的状态树中对应子状态

    const { item } = dialog.chestOpen;          // 是否打开宝箱

    if(!item) return;                           // 如果宝箱没打开,则退出

    const { items, maxItems } = inventory;      // 获取物品列表与上限

    if(items.length < maxItems) {               // 能装得下
    dispatch({                                  // 发送一个获取物品的 action
        type: 'GET_ITEM',
        payload: item
      });
    }
    else {                                      // 装不下了
      dispatch({                                // 发送一个物品过多的 action
        type: 'TOO_MANY_ITEMS',
        payload: item
      });
    }
  };
}

其中:

  • GET_ITEM 这个 action type 都是嵌套在别的 Actions 内部使用的
  • 也就是说它是一个内部使用的 action

处理 GET ITEM

GET_ITEM 在物品栏的 reducer.js 的内部进行处理,对应代码为:

const inventoryReducer = (state = initialState, { type, payload }) => {
    let newState;
    switch(type) {
        ……
        case 'GET_ITEM':
            newState = _cloneDeep(state); // 对原状态进行深拷贝 lodash.clonedeep
            // 将物品加入物品栏,其中为物品添加一个 uuid 标识
            newState.items.push({ ...payload, id: uuidv4() });
            return newState; // 返回新状态

装备装备

当我们见到一把剑后,想装备到玩家身上,是走的 src/features/inventory/actions/equip-item.js Action:

export default function equipItem(item) {
  return dispatch => {

    dispatch({
      type: 'EQUIP_ITEM',
      payload: item
    });
  };
}

这个 Actions 的处理者不是物品栏模块,而是 stats 模块(它表示玩家属性),位于 src/features/stats/reducer.js:

const statsReducer = (state = initialState, { type, payload }) => {
    let newState;
    switch(type) {
        case 'EQUIP_ITEM':
            newState = _cloneDeep(state);
            const item = payload;
            switch(item.type) {
                case 'weapon':  // 这里只以武器为例,省略其它类型的装备
                    // 如果已经装备有武器
                    if (newState.equippedItems.weapon) {
                        // 扣掉它的武器伤害,表示把它的属性加成下掉了
                        newState.damage -= newState.equippedItems.weapon.damage;
                    }
                    // 在加上新武器的属性加成
                    newState.damage += item.damage;
                    // 将已装备的武器指向当前武器
                    newState.equippedItems.weapon = item;
                    break;
                default:
            }
            return newState;

打怪赚钱

在游戏中打怪是可以赚钱的,我们来详细探究下这一过程。

我们采用自底向上的方法,先找到金钱存储在哪里,答案是在 stats 模块中,它的状态数据结构如下:

const initialState = {
  hp: 10,
  maxHp: 10,
  damage: 3,
  defence: 0,
  level: 1,
  exp: 0,
  expToLevel: 20,
  gold: 0,
  equippedItems: {}
};

其中:

  • gold 就表示金钱(金币💰)

赚钱的 Action 处理的代码为:

const statsReducer = (state = initialState, { type, payload }) => {
    let newState;
    switch(type) {
        case 'GET_GOLD':
            return { ...state, gold: state.gold + payload };

这段代码是非常简单的,要是现实中赚钱也这么简单就好了。

底层分析好了,我们向上看:谁发送 GET_GOLD 事件了?

找到两处:

  1. 打开箱子事件
  2. 卖出物品事件

我们选择第一个来看,开箱子,这个 Action 位于 src/features/dialog-manager/actions/open-chest.js:

import randomItem from '../dialogs/chest-loot/random-item';

export default function openChest() {
  return (dispatch, getState) => {

    const { level } = getState().stats;     // 获取当前地牢层数
    // 这个 boolean 表示是否会 roll 得一键装备
    let itemDrop = false;
    const chance = Math.floor(Math.random() * 100) + 1;
    if(chance <= 25) {              // 有 25% 的几率会得到一键装备
      itemDrop = randomItem(level); // 得到什么装备呢?在 randomItem 中自动生成
    }
    // 开箱子除了得物品,还能捡到金币,这里有一个算法
    // 获得金币 = (1~8) + 3 * 地牢层数
    const gold = (Math.floor(Math.random() * 8) + 1) + (level * 3);
    // 开箱子还会得经验,经验值是固定的
    const exp = (level * 5) + 5;

    // 下面开始分发事件
    dispatch({                      // 赚钱了
      type: 'GET_GOLD',
      payload: gold
    });
    dispatch({                      // 赚经验了
      type: 'GET_EXP',
      payload: exp
    });
    dispatch({                      // 设置箱子内物品
      type: 'SET_CHEST_DATA',
      payload: {
        exp,
        gold,
        item: itemDrop              // (这个物品会通过其他 actions 取走放进物品栏中)
      }
    });
  };
}

其中:

  • 我们看到了 GET_GOLD 事件分发,完整了整个过程的分析

我们可以更进一步,openChest 由谁发出呢?这里就是从数据层向 UI 层进发了。

找到触发的代码为 src/features/dialog-manager/dialogs/chest-loot/index.js,它是开箱子或者打怪后弹出的对话框。

这部分代码我们将在下一篇中进行详细介绍。

总结模式

前面看了这么多 Reducer、Actions 的实现,也感受到这个项目整体的整洁性。作为学习成功,我们将这种整洁的架构方式梳理下来。这样在未来的新项目中,我们也能进行迁移,写出同样优雅的代码。

首先,我们也将代码放在 src 目录下。在 src 下创建以下目录:

src
├── App.js                      // 主应用
├── __tests__                   // 测试文件
├── components                  // React 组件
├── config
│   ├── constants.js            // 常量定义
│   └── store.js                // store 定义,这是我们本篇分析的关键
├── reducers                    // 数据结构处理(Redux Reducers)
├── index.js                    // 应用入口
├── index.scss
└── utils                       // 工具类

其中:

  • 我将 features 改名为 reducers,跟 Redux 的命名方式相对应

在 reducers 目录下,将 APP 拆分为解耦的模块,比如:

reducers
├── Account
│   ├── actions
│       ├── action1.js
│       ├── action2.js
│       ├── action3.js
│   ├── reducer.js

其中 action 的写法为:

export default function action1(params) {
  return (dispatch, getState) => {
      ……
      let data = ……
      dispatch({
          type: "MY_ACTION_NAME",
          payload: data
      })
}

reducer.js 的写法为:

const initialState = {
  ……
};

const inventoryReducer = (state = initialState, { type, payload }) => {
    switch(type) {
        case "MY_ACTION_TYPE":
            ……
            return newState
        default:
            return state
    }
}

export default inventoryReducer;

其中:

  • 需要注意的是 newState 是需要深拷贝的

reducers 写好后,来到 config/store.js 下:

// 导入各个 reducers
import appState  from '../features/app-state/reducer';
import player    from '../features/player/reducer';
import dialog    from '../features/dialog-manager/reducer';

// 组装到一起
const rootReducer = combineReducers({
  appState,
  player,
  dialog,
  gameMenu,
  map,
  world,
  stats,
  inventory,
  monsters,
  snackbar
});

// 创建全局 store
const store = createStore(
  rootReducer
);

// 返回
export default store;

其中:

  • 以 appState 为例,它在 reducer.js 中 export default 名称为 appStateReducer,而我们在导入时将其命名为 appState,这个 appState 就是状态树中这一自状态树的名称

在应用入口中(src/App.js)将 Redux 与 React 相结合:

import { Provider }         from 'react-redux';
import store, { persistor } from './config/store';

class ConnectedApp extends Component {

  // refresh the local storage in case the redux store structure is old
  componentDidCatch() {
    localStorage.clear();
    window.location.reload();
  }

  render() {
    return(
      <Provider store={store}>
        <PersistGate
          loading={<Spinner />}
          persistor={persistor}>

          <App />

        </PersistGate>
      </Provider>
    );
  }
}

ReactDOM.render(<ConnectedApp />, document.getElementById('react-rpg'));

结论

在本文中,我们完成了对 ReactRPG 数据层的学习。我从中收获很大!😆