益智游戏开发-扫雷
yutool-cli高效工作套件中有几款休闲益智类的小游戏,本文介绍扫雷游戏是如何用代码实现的。
游戏规则
- 游戏在一个
m*n
的矩形雷区中进行 - 鼠标左键点击雷区中的某个格子触发扫描,如果扫描到地雷则游戏结束,反之有以下两种情况:
- 此格子周围九宫格内存在地雷(“周围九宫格内存在的地雷”在下文统一称为 感应地雷),则标识感应地雷数量,此次扫雷结束,游戏继续进行
- 此格子不存在感应地雷,则自动向四周扩散扫描,找寻存在感应地雷的格子坐标,找到坐标之后停止扩散并标识感应地雷数量,当向四周的所有扩散都停止之后,此次扫雷结束,游戏继续进行
- 当根据坐标的感应地雷数量推理出未扫描区域的地雷坐标后,可点击鼠标右键对地雷坐标进行预标识
- 已经被预标识的坐标可点击鼠标左键或者鼠标右键取消预标识
- 当雷区内所有非地雷坐标都被扫描出来,则游戏过关
设计思路
在设计扫雷游戏时的关键问题及解决方案整理如下。
如何设计雷区的坐标体系?
使用平面直角坐标系,雷区的列和行分别是坐标系的
x轴
和y轴
,雷区的每个格子都可以用x轴
、y轴
坐标来表示。地雷的坐标怎么确定?
m*n
的雷区如果有p
个地雷,通过构建一个包含1 ~ m*n
所有数值的乱序数组,取数组前p
个元素的值,通过将元素值转换为坐标之后,可得到p
个地雷坐标。(具体实现参考randomFields
方法)如何获取指定格子周围九宫格区域的所有坐标?
周围九宫格区域的8个格子坐标在x轴和y轴的偏移量相对于指定格子坐标来说是确定的,通过偏移量可快速计算出这8个坐标。
单局游戏开始后,首次扫描的格子坐标必定不是地雷,并且必定不存在感应地雷的机制如何实现?
首次扫描,判断如果将要触发地雷,则将地雷移至九宫格区域外;同样的,如果存在感应地雷,则将所有感应地雷也移至九宫格外。地雷移动之后,触发扫描扩散,游戏继续进行。
代码实现
完整代码
游戏UI使用semi design组件绘制。
以下是实现雷区及扫描操作的完整代码:
jsx
import { IconFixedStroked, IconFlag } from '@douyinfe/semi-icons'
import { Button, Row, Space } from '@douyinfe/semi-ui'
import { useEffect, useMemo } from 'react'
import useStateRef from 'react-usestateref'
import { fieldMark, nineGridDelta, randomFields } from '../data'
import style from './style'
export default (props) => {
const { mineConfig, mines, remainAmount, started, gameWin, gameOver, onStart, onWin, onOver, onRemainChange, cheat } = props
const [_scanned, setScanned, scannedRef] = useStateRef([])
const [_marked, setMarked, markedRef] = useStateRef([])
const [_sensored, setSensored, sensoredRef] = useStateRef({})
const [_boomMine, setBoomMine, boomMineRef] = useStateRef()
const columns = Array.from(Array(mineConfig.columns), (k, v) => v + 1)
const rows = Array.from(Array(mineConfig.rows), (k, v) => v + 1)
const fieldSize = mineConfig.rows * mineConfig.columns
const canFlag = useMemo(() => remainAmount > 0, [remainAmount])
const gameDone = useMemo(() => gameWin || gameOver, [gameWin, gameOver])
useEffect(() => {
if (!started) {
setScanned([])
setMarked([])
setSensored({})
setBoomMine()
}
}, [started])
//触发坐标
const sweep = (i, j) => {
if (!started && !isFlag(i, j)) {
firstSweep(i, j)
onStart()
}
if (gameWin || gameOver || isScanned(i, j)) {
return
}
if (isFlag(i, j)) {
toggleFlag(i, j)
return
}
if (isMine(i, j)) {
boomMineRef.current = fieldMark(i, j)
onOver()
return
}
spread(i, j)
if (isWin()) {
onWin()
}
}
//切换坐标的地雷标识
const toggleFlag = (i, j) => {
if (gameWin || gameOver || isScanned(i, j)) {
return
}
const mineMark = fieldMark(i, j)
const marked = [...markedRef.current]
const index = marked.indexOf(mineMark)
if (index > -1) {
marked.splice(index, 1)
setMarked(marked)
onRemainChange(remainAmount + 1)
} else if (canFlag) {
marked.push(mineMark)
setMarked(marked)
onRemainChange(remainAmount - 1)
}
}
//确保首次不会触发地雷并且周围感应不到地雷
const firstSweep = (i, j) => {
let excludes = excludeFields(i, j)
const sensorMines = sensor(i, j)
const sensorSize = sensorMines.length
const randomLimit = mineConfig.amount + 10
//如果触发地雷则将地雷转移到九宫格以外
if (isMine(i, j)) {
moveMine(i, j, randomLimit, excludes)
}
//感应到的地雷也转移到九宫格外
if (sensorSize > 0) {
sensorMines.forEach(({ x, y }) => {
moveMine(x, y, randomLimit, excludes)
})
}
}
//转移地雷,limit可根据抽屉原理计算得到
const moveMine = (i, j, limit, excludes) => {
const fields = randomFields(mineConfig.columns, mineConfig.rows, limit, excludes)
const mineIndex = mines.indexOf(fieldMark(i, j))
for (let k = 0; k < fields.length; k++) {
const { x, y } = fields[k]
if (!isMine(x, y) && !excludes.find(i => fieldMark(i.x, i.y) === fieldMark(x, y))) {
mines.splice(mineIndex, 1, fieldMark(x, y))
return
}
}
}
//计算以指定坐标为中心的九宫格坐标
const excludeFields = (i, j) => nineGridDelta.map(delta => ({ x: i + delta.offsetX, y: j + delta.offsetY }))
//扩散
const spread = (i, j) => {
if (!isValid(i, j) || isScanned(i, j) || isFlag(i, j) || isSensored(i, j)) {
return
}
const mineMark = fieldMark(i, j)
const scanned = [...scannedRef.current]
scanned.push(mineMark)
setScanned(scanned)
const sensorMineAmount = sensor(i, j).length
if (sensorMineAmount > 0) {
const sensored = { ...sensoredRef.current }
sensored[mineMark] = sensorMineAmount
setSensored(sensored)
} else {
nineGridDelta.forEach(delta => {
if (delta.offsetX === 0 && delta.offsetY === 0) {
return
}
spread(i + delta.offsetX, j + delta.offsetY)
})
}
}
//感应周围九宫格的地雷坐标
const sensor = (i, j) => {
let sensorMines = []
nineGridDelta.forEach(delta => {
if (delta.offsetX === 0 && delta.offsetY === 0) {
return
}
const x = i + delta.offsetX
const y = j + delta.offsetY
if (isValid(x, y) && isMine(x, y)) {
sensorMines.push({ x, y })
}
})
return sensorMines
}
//判断游戏是否胜利
const isWin = () => (fieldSize === mines.length + scannedRef.current.length)
//判断坐标是否为地雷
const isMine = (i, j) => mines.includes(fieldMark(i, j))
//判断坐标是否已经被扫描过
const isScanned = (i, j) => scannedRef.current.includes(fieldMark(i, j))
//判断坐标是否已经被标识过
const isFlag = (i, j) => markedRef.current.includes(fieldMark(i, j))
//判断坐标是否已经感应过
const isSensored = (i, j) => sensoredRef.current[fieldMark(i, j)]
//判断坐标是否为触发爆炸的地雷
const isBoom = (i, j) => boomMineRef.current === fieldMark(i, j)
//判断坐标的地雷标识是否正确
const isCorrectFlag = (i, j) => gameOver && isMine(i, j) && isFlag(i, j)
//判断坐标的地雷是否被成功扫除
const isSwept = (i, j) => gameWin && isMine(i, j)
//判断坐标是否位于雷区内
const isValid = (i, j) => i > 0 && i <= mineConfig.columns && j > 0 && j <= mineConfig.rows
//根据坐标感应到的地雷数量设置数字颜色
const sensorColor = (i, j) => {
const sensorAmount = sensoredRef.current[fieldMark(i, j)]
return sensorAmount === 1 ? style.sensorColor.one :
sensorAmount === 2 ? style.sensorColor.two :
sensorAmount === 3 ? style.sensorColor.three :
sensorAmount === 4 ? style.sensorColor.four :
style.sensorColor.other
}
return (
<Row type='flex' justify='space-around'>
<Space spacing={0} vertical>
{rows.map(j =>
<Space key={j} spacing={0}>
{columns.map(i =>
<Button
key={i}
style={{
width: 40,
height: 40,
border: '1px solid var(--semi-color-border)',
borderRadius: 0,
backgroundColor: isBoom(i, j) ? style.boomBg :
isSwept(i, j) || isCorrectFlag(i, j) || isScanned(i, j) || (gameDone && isMine(i, j)) ? style.scannedBg :
gameDone ? style.notScannedBg : null
}}
onClick={() => sweep(i, j)}
onContextMenu={(e) => {
e.preventDefault()
toggleFlag(i, j)
}}>
{cheat && !gameDone && isMine(i, j) ? <IconFixedStroked /> : null}
{gameDone && isMine(i, j) ?
<IconFixedStroked
style={{
color: isBoom(i, j) ? style.boomColor :
isSwept(i, j) ? style.sweptColor :
style.mineColor
}} />
: isFlag(i, j) ?
<IconFlag style={{ color: isCorrectFlag(i, j) ? style.correctColor : style.flagColor }} />
: null
}
<span className='mono-font' style={{ color: sensorColor(i, j), fontSize: 18 }}>{sensoredRef.current[fieldMark(i, j)]}</span>
</Button>
)}
</Space>
)}
</Space>
</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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
js
export default {
boomColor: 'var(--semi-color-text-2)',
flagColor: 'var(--semi-color-danger)',
mineColor: 'var(--semi-color-text-0)',
sensorColor: {
one: 'rgba(var(--semi-blue-5), 1)',
two: 'rgba(var(--semi-green-5), 1)',
three: 'rgba(var(--semi-red-5), 1)',
four: 'rgba(var(--semi-orange-5), 1)',
other: 'rgba(var(--semi-grey-5), 1)'
},
sweptColor: 'var(--semi-color-success)',
correctColor: 'var(--semi-color-success)',
scannedBg: 'var(--semi-color-bg-0)',
boomBg: 'var(--semi-color-danger)',
notScannedBg: 'var(--semi-color-fill-0)'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
js
export const levelConfig = {
basic: {
amount: 10,
columns: 9,
rows: 9,
label: '基础'
},
intermediate: {
amount: 40,
columns: 16,
rows: 16,
label: '中级'
},
professional: {
amount: 99,
columns: 30,
rows: 16,
label: '专家'
}
}
export const nineGridDelta = [
{ offsetX: -1, offsetY: -1 },
{ offsetX: 0, offsetY: -1 },
{ offsetX: 1, offsetY: -1 },
{ offsetX: -1, offsetY: 0 },
{ offsetX: 0, offsetY: 0 },
{ offsetX: 1, offsetY: 0 },
{ offsetX: -1, offsetY: 1 },
{ offsetX: 0, offsetY: 1 },
{ offsetX: 1, offsetY: 1 }
]
export const fieldMark = (x, y) => `${x}_${y}`
export const randomFields = (columns, rows, limit, excludes = []) => {
const fields = []
const fieldSize = columns * rows
let array = Array.from(Array(fieldSize), (v, k) => k + 1)
//将数组打乱顺序,取前limit个数转换为坐标
array.sort(() => 0.5 - Math.random())
let i = 0
while (i < fieldSize && limit > 0) {
const num = array[i]
const isColLast = num % rows === 0
const x = parseInt(num / rows) + (isColLast ? 0 : 1)
const y = isColLast ? rows : num % rows
if (excludes.findIndex(item => fieldMark(item.x, item.y) === fieldMark(x, y)) === -1) {
fields.push({ x, y })
limit--
}
i++
}
return fields
}
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
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
评论区留言准则:
1. 本评论区禁止传播封建迷信、吸烟酗酒、低俗色情、赌博诈骗等任何违法违规内容。
2. 当他人以不正当方式诱导打赏、私下交易,请谨慎判断,以防人身财产损失。
3. 请勿轻信各类招聘征婚、代练代抽、私下交易、购买礼包码、游戏币等广告信息,谨防网络诈骗。