地图编辑器

基于 React+Typescript+redux 的地图编辑器

故地重游

为什么把五个月前的项目拿出来谈,一是加入了React 兴趣小组,但苦于工作中没有施展的空间,没有东西分享。二是这个项目还算比较好玩,有一些可分享的点。
往期文章

地图编辑器

《地图编辑器》是一种所见即所得的游戏地图制作工具,它辅助设计和输出地图数据,包括创建、编辑、存储和管理游戏地图数据。
地图编辑器读取和使用游戏资源,并按照游戏程序规约输出相应格式的地图数据,游戏程序(客户端和服务器)通过地图数据构建游戏场景,将其呈现给用户。 —百度百科

游戏资源示例

演示

现有功能

  • 图层:新建、上移、下移、重命名、删除
  • 图块:新建、删除、编辑大小、添加额外属性
  • 工具:橡皮擦、导入、导出、撤销、取消撤销、全局属性
  • 网格:是否显示网格

redux 是什么

复杂的JS应用,需要管理各种复杂的状态,可能是组件与组件间/页面与页面间,这些状态难以维护,特别是在异步和变化中。由此,Redux产生了。

前面一大段话并没有提到 React,那是因为 Redux 是独立的库,只要你的应用需要管理复杂的全局状态,你都可以用它。而React因为将 state 数据处理问题留给了开发者,所以我们再使用 React 时,常常需要 Redux 来管理状态(hook出现前)。

redux 三大原则

  • 单一数据源:顶级state
  • state只读:只能通过触发action来修改
  • 纯函数用来执行修改:reducer最好是纯函数

    redux代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    /* action.js */
    import * as constants from '../../constants'
    console.log(constants)
    export interface IncrementEnthusiasm {
    type: constants.INCREMENT_ENTHUSIASM;
    }

    export interface DecrementEnthusiasm {
    type: constants.DECREMENT_ENTHUSIASM;
    }

    export type EnthusiasmAction = IncrementEnthusiasm | DecrementEnthusiasm;

    export function incrementEnthusiasm(): IncrementEnthusiasm {
    return {
    type: constants.INCREMENT_ENTHUSIASM
    }
    }

    export function decrementEnthusiasm(): DecrementEnthusiasm {
    return {
    type: constants.DECREMENT_ENTHUSIASM
    }
    }
    /* reducer.js */
    import { EnthusiasmAction } from '../actions';
    import { enthusiasm } from '../../types/index';
    import { INCREMENT_ENTHUSIASM, DECREMENT_ENTHUSIASM } from '../../constants/index';
    import layer from './layer'
    import { block } from './block'
    const initState= {
    enthusiasmLevel: 1,
    languageName: 'TypeScript',
    };
    export function enthusiasm(state: enthusiasm = initState, action: EnthusiasmAction): enthusiasm {
    switch (action.type) {
    case INCREMENT_ENTHUSIASM:
    return { ...state, enthusiasmLevel: state.enthusiasmLevel + 1 };
    case DECREMENT_ENTHUSIASM:
    return { ...state, enthusiasmLevel: Math.max(1, state.enthusiasmLevel - 1) };
    }
    return {...state}
    }

    /* store */


    import {createStore, applyMiddleware} from 'redux';
    import * as reducer from '../reducers';
    import thunk from 'redux-thunk';
    import { combineReducers } from 'redux-immutable'

    //创建一个 Redux store 来以存放应用中所有的 state,应用中应有且仅有一个 store。
    console.log('包装好的reducer')
    console.log(reducer)
    var store = createStore(
    combineReducers(reducer), // 将所有reducer合并成一个大的reducer
    applyMiddleware(thunk) //将所有中间件作为一个数组,并执行,
    );

    export default store;

搭配React 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import ActionMethods from '../actions'
import TodoList from '../todoList'
// 返回值监听的state改变,则重新渲染组件
const mapStateToProps = state => {
return {
todos: state.todos
}
}
// 将action绑定在props里
const mapDispatchToProps = dispatch => {
return {
onTodoClick: id => {
dispatch(ActionMethods(id))
}
}
}

const VisibleTodoList = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList)

export default VisibleTodoList

Immutable 是什么

上文,reducer的操作用了 ...操作符,来合并 state,但这终归是浅合并,如果对象层级大于2层,那该函数就不属于纯函数,它可能被外部环境修改,这违背了reducer的原则。因此我们需要稳妥、高效的操作 state。 Immutable 就是来干这个的。

当数据修改时,它只会修改相关节点以及它的父节点。在节省内存与引用赋值中间取了最优点。这也是为什么我们不用深拷贝的原因。

这里简单的阐述下 Immutable的 Api 就行了。

Immutable API

Immutable 有两个常用的数据类型:Map 和 List,这里很容易和原生混淆,它的作用也和原生类似。前者储存对象后者数组。
将普通对象转为 Immutable 对象, 如果是数组,会转成List,如果是对象,会转为 Map。

1
2
3
4
// 将 obj 转为 Map or List
Immutable.fromJS(obj)
// 再转成普通对象
Immutable.toJS(obj)

以下Demo 足够本项目用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

const Immutable = require('immutable')
const obj = {
inf: {name: 'DuHao'},
money: [9999999999]
}
const obj1 = Immutable.fromJS(obj)
const obj2 = obj1.setIn(['inf', 'name'], 'GuoFuCheng')
console.log(obj2.getIn(['inf', 'name']))
console.log(obj1.getIn(['inf', 'name']))
console.log(obj2 === obj1)

const obj3 = obj2.setIn(['money', 0], 0)
console.log(obj3.getIn(['money', 0]))
console.log(obj2.getIn(['money', 0]))

项目应用:

1
2
3
4
5
6
7
8
9
10
11
const initState= fromJS({
blockList: List([])
});
function delBlock(state: Map<any, any>, payload: {id: number}): Map<any, Array<blockItem>> {
const blocks = state.get('blockList').splice(payload.id, 1)
return state.set('blockList', blocks)
}
function createBlock(state: Map<string, any>, payload: blockItem): Map<any, Array<blockItem>> {
const blocks = state.get('blockList').push(payload)
return state.set('blockList', blocks)
}

store 结构

store 分为两个结构

  • 图层
    • 全局数据(图块基本属性、网格属性、橡皮擦属性、项目名称……)
    • 图层数组
      • 图块二维数组(图片地址、大小、坐标、额外属性)
      • 当前图层属性(名称、visible……)
  • 可用图块数组
    • (图片地址、大小、坐标、额外属性)

整个系统,核心功能就是将可用图块数组填充到对应的图层中。
接下来我会根据模块拆解整个系统。

图层 之 上移下移

图层的层级关系直接根据数组下标确定的。因此当需要图层上移下移时,只需要交换数组元素位置就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
enum upDown {
UP,
DOWN
}
function changePosition(type: upDown) {
if (this.props.curLayerId < 0) {
return;
}
const index = this.props.layers.findIndex(item => {
return item.id === this.props.curLayerId;
});
this.props.switchLayer({
type,
index
});
}


function switchLayer(state: layer, payload: SWITCH_LAYER_PAYLOAD): layer {
const index = payload.index;
const layers = [...state.layers];
if (payload.type === upDown.DOWN) {
if (index === state.layers.length - 1) {
return state;
}
// 交换位置
[layers[index], layers[index + 1]] = [layers[index + 1], layers[index]];
} else {
if (index === 0) {
return state;
}
[layers[index], layers[index - 1]] = [layers[index - 1], layers[index]];
}
return { ...state, layers: layers };
}

移动只有两种,上移和下移,所以这里用了枚举。
交换位置通过解构来实现,删除和新建,也没什么说的,这里就不赘述了。

工具 之 橡皮擦

当用户选中橡皮擦时,会执行以下 reducer

1
2
3
4
5
6
function switchErser(state: layer) {
if(!state.eraser) {
return {...state, eraser: !state.eraser, curBlock: undefined}
}
return {...state, eraser: !state.eraser}
}

切换 eraser 的值
并且,当用户选中一个或多个图块时,删除当前图层的图块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function delErserBlock(state: layer, payload: Array<{x:number, y: number}>) {
const layers = [...state.layers]
const layer = Object.assign({}, layers.find(item => {
return item.id === state.curLayerId
}) as LayerItem)
payload.map((matrix) => {
const x = matrix.x
const y = matrix.y
if(!layer.matrix.get(x) || !layer.matrix.getIn([x, y])) {
return matrix
}
const item = layer.matrix.getIn([x, y])
const obj = {
src: undefined,
height: state.boxHeight,
width: state.boxWidth,
row: item.row,
col: item.col,
name: '',
extra: []
}
layer.matrix = layer.matrix.setIn([x, y], obj)
return matrix
})
const rLayers = layers.map((item) => {
if(item.id === state.curLayerId ) {
return layer
}
return item
})
console.log(rLayers)
return {...state, layers: rLayers}
}

工具 之 撤销和反撤销

地图编辑器包含了撤销和反撤销的操作,不管是 reducer 或者 Immutable,都是纯函数,这意味着数据在每一阶段都可以回溯。那我们将每一阶段的 state 都储存起来,当想回溯的时候拿出来就行了。
我看到 redux “周边” 正好有这个库,就直接拿来用了。它的用法也很简单。 用 undoable 方法包裹住 reducer 就行了。

1
2
3
4
5
6
7
8
9
10
11

import undoable from 'redux-undo'

export default undoable(reducerAll,{
debug: true
})
// 组件内使用

import { ActionCreators as UndoActionCreators } from 'redux-undo'
dispatch(UndoActionCreators.undo()
dispatch(UndoActionCreators.redo()

工具 之 导出和导入

导出

地图编辑器导出时有两个文件:地图编辑器项目文件(可再次导入编辑) 和 游戏地图文件(提供给游戏解析)
它们无非是对store进行了属性删除,然后调用原生 Blob 对象,生成json文件。 这里只用游戏地图文件举例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { saveAs } from 'file-saver'
export function saveGameFile(file: StoreState) {
file.layer.layers.forEach(item => {
item.matrix.forEach(row => {
row.forEach(col => {
// 删掉base64字符串,游戏项目会自己维护图片资源
delete col.src
})
})
})
const delName = ['curBlock', 'curLayerId', 'eraser', 'showLine', 'past', 'future']
// 删除不用的属性
delName.map((val) => {
delete file.layer[val]
})
const fileString = JSON.stringify({...file.layer})
var blob = new Blob([fileString], { type: 'text/plain;charset=utf-8' })
saveAs(blob, `${file.layer.name}.game.json`)
}

导入

导入和导出差不多,将保存的json文件解析出来,赋值给store就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function importJson(ev: any) {
const dom = document.getElementById('jsonUpFile') as HTMLInputElement
const files = dom.files as FileList
if (!files || !files.length) {
return
}
Array.from(files).map(file => {
const reader = new FileReader()
reader.readAsText(file)
reader.onloadstart = function() {
console.log('文件上传处理......')
}
console.log(file)
//操作完成
reader.onload = () => {
const state: any = importFile(
reader.result as string,
file.name.split('.')[0]
)
// 分别赋值block 和 layer
this.props.importBlock(state.block.blockList)
this.props.importLayer(state.layer)
}
})
}

工具 之 网格

网格其实是新增了一张图层,覆盖在最顶层。

图块 之 上传图片

上传图片直接用的 input [type=file],且支持多张上传。并且,用 FileReader 对象,将图片文件转为base64格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

public changeImg(ev: any) {
const dom = document.getElementById('imgUpFile') as HTMLInputElement
const files = dom.files as FileList
if (!files || !files.length) {
return
}
Array.from(files).map(file => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onloadstart = function() {
console.log('文件上传处理......')
}
//操作完成
reader.onload = () => {
const image = new Image()
image.src = reader.result as string
image.onload = () => {
this.props.createBlock({
src: reader.result as string,
width: image.width,
height: image.height,
name: file.name,
// 随机数在reducer之前执行
id: Math.random(),
extra: []
})
}
}
})
}

图块 之 额外属性

对于游戏开发者,可能需要对同一类型的图块就行不同的操作,这是就需要一个“标记”。
地图编辑器支持给图块添加额外属性,对于每个图块对象都有一个 extra 数组,储存着额外属性。

1
2
3
4
5
6
7
8
9
10
11
function changeKey(item: any, event: any) {
const extra: any = [...this.state.extra]
const index = this.state.extra.findIndex(ev => {
return ev === item
})
const key = Object.keys(item)[0] ? Object.keys(item)[0] : ''
const obj = {}
obj[event.target.value] = item[key]
extra.splice(index, 1, obj)
this.setState({extra})
}

结束

END