地图编辑器
基于 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 | import ActionMethods from '../actions' |
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
11const 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
35enum 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 };
}
移动只有两种,上移和下移,所以这里用了枚举。
交换位置通过解构来实现,删除和新建,也没什么说的,这里就不赘述了。
工具 之 橡皮擦
当用户选中橡皮擦时,会执行以下 reducer1
2
3
4
5
6function 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
33function 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
19import { 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
25function 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
11function 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