益智游戏开发-推箱子
yutool-cli高效工作套件中有几款休闲益智类的小游戏,本文介绍推箱子游戏是如何用代码实现的。

游戏规则
- 玩家控制角色上下左右移动
- 碰到墙壁无法行进
- 碰到箱子,在箱子的移动方向上没有障碍(障碍指的是墙壁或者另一个箱子)可将箱子推动
- 将所有箱子推到指定的目的地,游戏过关
设计思路
在设计推箱子游戏时的关键问题及解决方案整理如下。
游戏界面如何绘制?
游戏界面在一个
m * n(默认是 12 * 9)的区域内进行,初始化一个二维数组,将区域内的每个格子的i、j坐标与二维数组的两层下标对应,数组元素对应的值对应初始游戏界面元素的类型。例如:0表示空白区域,可进行移动;1表示墙壁,无法移动;2表示箱子目的地。如何判断是否能进行移动以及箱子是否能推动?
如果角色行进的坐标为墙壁则无法移动。如果行进的坐标上有箱子,并且箱子行进的坐标不是墙壁或者另一个箱子,则可以推动箱子并移动。
如何实现“回退一步”功能?
保存每一步有效移动(角色确确实实移动了,不是碰到墙壁或者箱子推不动的情况)之前的角色坐标及所有箱子的坐标,回退时直接将保存的角色坐标和所有箱子坐标覆盖当前的状态即可。具体可参考
undo方法的代码实现。
代码实现
完整代码
游戏UI使用semi design组件绘制。
以下是实现推箱子的完整代码:
jsx
import util from '@/util'
import { IconInteractiveStroked, IconMarginStroked, IconRefresh2, IconUndo, IconVoteVideoStroked } from '@douyinfe/semi-icons'
import { Button, ButtonGroup, Col, Row, Space, Toast } from '@douyinfe/semi-ui'
import { useEffect } from 'react'
import useStateRef from 'react-usestateref'
import style from './style'
export default (props) => {
const { player, boxes, initData } = props
const [_playerLoc, setPlayerLoc, playerLocRef] = useStateRef({})
const [_blanks, setBlanks, blanksRef] = useStateRef([])
const [_walls, setWalls, wallsRef] = useStateRef([])
const [_boxes, setBoxes, boxesRef] = useStateRef([])
const [_targets, setTargets, targetsRef] = useStateRef([])
const [_actions, setActions, actionsRef] = useStateRef([])
useEffect(() => {
setPlayerLoc(player)
setBoxes(boxes)
init(initData)
setActions([])
initKeydownListeners()
document.activeElement.blur()
}, [initData])
const init = (data) => {
const blankCells = []
const wallCells = []
const targetCells = []
for (let i = 0; i < data.length; i++) {
const row = data[i]
for (let j = 0; j < row.length; j++) {
const type = row[j]
if (type === 0) {
blankCells.push({ i, j })
} else if (type === 1) {
wallCells.push({ i, j })
} else if (type === 2) {
blankCells.push({ i, j })
targetCells.push({ i, j })
}
}
}
setBlanks(blankCells)
setWalls(wallCells)
setTargets(targetCells)
}
const initKeydownListeners = () => {
window.onkeydown = (e) => {
if (e.key === 'ArrowUp') {
move(-1, 0)
} else if (e.key === 'ArrowDown') {
move(1, 0)
} else if (e.key === 'ArrowLeft') {
move(0, -1)
} else if (e.key === 'ArrowRight') {
move(0, 1)
}
}
}
const move = (offsetX, offsetY) => {
const current = { ...playerLocRef.current }
const boxCells = [...boxesRef.current]
const newActions = [...actionsRef.current]
newActions.push({ player: { ...current }, boxes: [...boxCells] })
const next = { i: current.i + offsetX, j: current.j + offsetY }
//判断是否推到了箱子
if (isBox(next.i, next.j)) {
const boxNext = { i: next.i + offsetX, j: next.j + offsetY }
//判断箱子是否可推动
if (isBlank(boxNext.i, boxNext.j) && !isBox(boxNext.i, boxNext.j)) {
setPlayerLoc(next)
//重新设置推动箱子的坐标
const boxIndex = boxCells.findIndex(item => item.i === next.i && item.j === next.j)
boxCells.splice(boxIndex, 1, boxNext)
setBoxes(boxCells)
setActions(newActions)
}
} else if (isBlank(next.i, next.j)) {
setPlayerLoc(next)
setActions(newActions)
}
checkWin()
}
const checkWin = () => {
if (util.isEmpty(boxesRef.current)) {
return
}
let win = true
for (let i = 0; i < boxesRef.current.length; i++) {
const box = boxesRef.current[i];
if (!isTarget(box.i, box.j)) {
win = false
break
}
}
if (win) {
Toast.success('恭喜过关')
setActions([])
window.onkeydown = (e) => { }
}
}
const undo = () => {
const newActions = [...actionsRef.current]
const lastAction = newActions.pop()
setPlayerLoc(lastAction.player)
setBoxes(lastAction.boxes)
setActions(newActions)
}
const reset = () => {
setPlayerLoc(player)
setBoxes(boxes)
init(initData)
setActions([])
}
const isBlank = (i, j) => blanksRef.current.findIndex(item => item.i === i && item.j === j) > -1
const isWall = (i, j) => wallsRef.current.findIndex(item => item.i === i && item.j === j) > -1
const isBox = (i, j) => boxesRef.current.findIndex(item => item.i === i && item.j === j) > -1
const isTarget = (i, j) => targetsRef.current.findIndex(item => item.i === i && item.j === j) > -1
const isPlayer = (i, j) => i === playerLocRef.current?.i && j === playerLocRef.current?.j
const isCorrect = (i, j) => isBox(i, j) && isTarget(i, j)
return (
<Row gutter={[0, 16]} type='flex' justify='space-around'>
<Col span={24}>
<Space spacing={0} vertical>
<>
{initData.map((row, i) =>
<Space key={`row_${i}`}>
<Space key={i} spacing={0}>
{row.map((type, j) =>
<Button
key={`${i}_${j}`}
style={{
width: 40,
height: 40,
border: '1px solid var(--semi-color-border)',
borderRadius: 0,
backgroundColor: isCorrect(i, j) ? style.correctColor :
isBox(i, j) ? style.boxColor :
isTarget(i, j) ? style.targetColor :
isWall(i, j) ? style.wallColor :
isBlank(i, j) ? style.blankColor :
null
}}
icon={
isBox(i, j) ? <IconMarginStroked style={{ color: 'var(--semi-color-text-2)' }} /> :
isPlayer(i, j) ? <IconVoteVideoStroked style={{ color: 'var(--semi-color-text-2)' }} size='extra-large' /> :
isTarget(i, j) ? <IconInteractiveStroked style={{ color: 'var(--semi-color-text-2)' }} /> :
null
} />
)}
</Space>
</Space>
)}
</>
</Space>
</Col>
<Col span={24}>
<Row gutter={[0, 16]} type='flex' justify='center'>
{util.isEmpty(actionsRef.current) ? null :
<ButtonGroup>
<Button type='tertiary' icon={<IconUndo />} title='回退一步' onClick={undo}>回退一步</Button>
{player ?
<Button type='tertiary' icon={<IconRefresh2 />} title='重新开始' onClick={reset}>重新开始</Button>
: null
}
</ButtonGroup>
}
</Row>
</Col>
</Row>
)
}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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
评论区留言准则:
1. 本评论区禁止传播封建迷信、吸烟酗酒、低俗色情、赌博诈骗等任何违法违规内容。
2. 当他人以不正当方式诱导打赏、私下交易,请谨慎判断,以防人身财产损失。
3. 请勿轻信各类招聘征婚、代练代抽、私下交易、购买礼包码、游戏币等广告信息,谨防网络诈骗。