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 这三个目录是我们感兴趣的
在这个类中定义了 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;
其中:
作者对 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
……
其中:
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
了解数据结构就是了解 Redux Reducer,也就是 features 下的这些模块。
我从中选择了一个比较好入手的,即 inventory。
我们先来看下 inventory 的初始状态(位于 reducer.js):
const initialState = { items: [], maxItems: MAX_ITEMS };
其中:
通过初始状态,我们可以得知物品栏的数据结构是什么样。
玩 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 内部使用的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: {} };
其中:
赚钱的 Action 处理的代码为:
const statsReducer = (state = initialState, { type, payload }) => { let newState; switch(type) { case 'GET_GOLD': return { ...state, gold: state.gold + payload };
这段代码是非常简单的,要是现实中赚钱也这么简单就好了。
底层分析好了,我们向上看:谁发送 GET_GOLD
事件了?
找到两处:
我们选择第一个来看,开箱子,这个 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 // 工具类
其中:
在 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;
其中:
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;
其中:
在应用入口中(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 数据层的学习。我从中收获很大!😆