1. 项目背景与核心目标
扫雷游戏作为Windows系统自带的经典游戏,承载了一代人的记忆。这个看似简单的游戏背后,其实蕴含着算法设计、交互逻辑和状态管理等多个计算机科学领域的知识要点。在移动互联网时代,将扫雷游戏移植到Android平台,不仅是对经典游戏的致敬,更是学习Android应用开发的绝佳实践项目。
这个项目的核心目标是通过Android Studio开发一个功能完整的扫雷游戏,实现以下关键特性:
- 可自定义的棋盘大小(如9×9初级、16×16中级、30×16高级)
- 符合经典扫雷规则的雷区生成与数字提示系统
- 支持点击翻开、长按标记地雷等标准交互操作
- 计时器和剩余雷数显示等游戏状态管理
- 游戏胜利/失败的判定与界面反馈
选择这个项目进行开发,主要基于以下几个技术学习价值:
- 掌握Android基础UI组件的使用(特别是GridView和自定义View)
- 理解MVC/MVVM架构在游戏开发中的应用
- 学习触摸事件处理和手势识别
- 实践游戏状态管理和数据持久化
- 熟悉Android Studio开发环境和调试工具
提示:虽然扫雷看起来简单,但要实现一个体验良好的版本,需要考虑很多细节问题,比如性能优化、不同屏幕尺寸适配、状态恢复等,这些都是很好的学习机会。
2. 开发环境与技术选型
2.1 Android Studio配置要点
开发Android扫雷游戏,首推使用Android Studio作为IDE。当前稳定版本(如Giraffe 2022.3.1)已经能很好地支持本项目的所有开发需求。安装时需要注意:
- JDK版本:建议使用JDK 17(LTS版本),这是Android Studio Giraffe的推荐配置
- Android SDK:确保安装API 34(Android 14)和API 33(Android 13)两个版本
- Gradle配置:使用Gradle 8.0以上版本,build.gradle中配置如下:
android { compileSdk 34 defaultConfig { minSdk 24 targetSdk 34 versionCode 1 versionName "1.0" } }2.2 游戏引擎选择
对于扫雷这类逻辑密集型游戏,我们有以下几种技术实现方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯Canvas绘制 | 性能好,完全控制渲染 | 开发效率低 | 追求极致性能 |
| View系统(GridView) | 开发简单,复用现有组件 | 灵活性受限 | 快速原型开发 |
| Jetpack Compose | 声明式UI,现代化 | 学习曲线较陡 | 长期维护项目 |
考虑到扫雷的单元格交互需求,本项目采用GridView+自定义Adapter的方案,这是平衡开发效率和性能的最佳选择。GridView天生适合棋盘类布局,其回收机制能很好地处理大尺寸雷区。
2.3 项目结构设计
采用分层架构设计,核心模块划分如下:
com.example.minesweeper/ ├── model/ # 数据模型 │ ├── Cell.kt # 单元格数据类 │ ├── Game.kt # 游戏状态管理 │ └── Board.kt # 雷区生成算法 ├── view/ # 界面相关 │ ├── CellView.kt # 自定义单元格视图 │ └── GameActivity.kt # 主界面 ├── adapter/ # 适配器 │ └── BoardAdapter.kt # GridView适配器 └── utils/ # 工具类 ├── Preferences.kt # 游戏设置存储 └── SoundManager.kt # 音效管理这种结构清晰分离了游戏逻辑和界面表现,便于后续扩展和维护。
3. 核心游戏逻辑实现
3.1 雷区生成算法
扫雷游戏的核心算法之一是随机生成雷区。这里需要考虑两个关键点:
- 确保首次点击位置一定是安全的(不踩雷)
- 雷的分布要真正随机,避免出现明显的模式
实现代码示例(Kotlin):
fun generateMines(firstClickPos: Int, mineCount: Int, totalCells: Int): Set<Int> { val mines = mutableSetOf<Int>() val random = Random(System.currentTimeMillis()) // 确保首次点击位置周围8格都没有雷 val excludePositions = getAdjacentPositions(firstClickPos) + firstClickPos while (mines.size < mineCount) { val pos = random.nextInt(totalCells) if (pos !in excludePositions) { mines.add(pos) } } return mines } private fun getAdjacentPositions(pos: Int): List<Int> { val row = pos / columns val col = pos % columns val adjacent = mutableListOf<Int>() for (r in (row-1)..(row+1)) { for (c in (col-1)..(col+1)) { if (r in 0 until rows && c in 0 until columns && !(r == row && c == col)) { adjacent.add(r * columns + c) } } } return adjacent }3.2 数字提示计算
每个非雷单元格需要显示周围8格中的地雷数量。这个计算可以在生成雷区时同步完成:
fun calculateAdjacentMines(mines: Set<Int>, position: Int): Int { if (position in mines) return -1 // 是雷 var count = 0 for (adjPos in getAdjacentPositions(position)) { if (adjPos in mines) count++ } return count }3.3 空白区域展开算法
当玩家点击一个周围没有雷的空白单元格时,需要自动展开所有相邻的空白区域,直到遇到数字边界。这需要使用广度优先搜索(BFS)算法:
fun expandEmptyArea(clickedPos: Int) { val queue = LinkedList<Int>() val visited = BooleanArray(totalCells) queue.offer(clickedPos) visited[clickedPos] = true while (queue.isNotEmpty()) { val current = queue.poll() revealCell(current) if (getAdjacentMines(current) == 0) { for (neighbor in getAdjacentPositions(current)) { if (!visited[neighbor] && !isRevealed(neighbor)) { visited[neighbor] = true queue.offer(neighbor) } } } } }4. 用户界面设计与实现
4.1 游戏主界面布局
使用ConstraintLayout作为根布局,包含以下几个关键部分:
<androidx.constraintlayout.widget.ConstraintLayout> <!-- 游戏状态栏 --> <LinearLayout android:id="@+id/status_bar" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"> <TextView android:id="@+id/mines_count" /> <Button android:id="@+id/reset_btn" /> <TextView android:id="@+id/timer" /> </LinearLayout> <!-- 雷区网格 --> <GridView android:id="@+id/board_grid" app:layout_constraintTop_toBottomOf="@id/status_bar" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" android:numColumns="@integer/board_columns" /> <!-- 游戏结束弹窗 --> <include layout="@layout/game_over_dialog" /> </androidx.constraintlayout.widget.ConstraintLayout>4.2 自定义单元格视图
每个格子需要根据状态显示不同样式:
class CellView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private var cellState: CellState = CellState.COVERED private var adjacentMines = 0 private var isMine = false override fun onDraw(canvas: Canvas) { when (cellState) { CellState.COVERED -> drawCoveredCell(canvas) CellState.FLAGGED -> drawFlaggedCell(canvas) CellState.REVEALED -> { if (isMine) drawMineCell(canvas) else drawNumberCell(canvas) } CellState.EXPLODED -> drawExplodedCell(canvas) } } private fun drawNumberCell(canvas: Canvas) { // 绘制背景 canvas.drawColor(ContextCompat.getColor(context, R.color.cell_revealed)) if (adjacentMines > 0) { // 绘制数字 val textPaint = Paint().apply { color = getNumberColor(adjacentMines) textSize = resources.getDimension(R.dimen.cell_text_size) textAlign = Paint.Align.CENTER } val x = width / 2f val y = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f canvas.drawText(adjacentMines.toString(), x, y, textPaint) } } // 其他绘制方法... }4.3 手势事件处理
扫雷需要区分点击和长按两种操作:
board_grid.setOnItemClickListener { _, _, position, _ -> viewModel.onCellClicked(position) } board_grid.setOnItemLongClickListener { _, _, position, _ -> viewModel.onCellLongClicked(position) true // 消费事件 }在ViewModel中处理游戏逻辑:
fun onCellClicked(position: Int) { if (gameState.value != GameState.PLAYING) return when (val cell = cells.value?.get(position)) { null -> return !isRevealed -> { if (cell.isFlagged) return if (cell.isMine) { // 踩到雷,游戏结束 revealAllMines() _gameState.value = GameState.LOST } else { // 安全区域 if (cell.adjacentMines == 0) { expandEmptyArea(position) } else { revealCell(position) } checkWinCondition() } } } } fun onCellLongClicked(position: Int) { if (gameState.value != GameState.PLAYING) return val cell = cells.value?.get(position) ?: return if (!cell.isRevealed) { toggleFlag(position) } }5. 高级功能与优化
5.1 游戏状态持久化
为了防止意外退出导致游戏进度丢失,需要实现游戏状态保存:
// 保存状态 override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putParcelable("game_state", viewModel.saveGameState()) } // 恢复状态 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { viewModel.restoreGameState(savedInstanceState.getParcelable("game_state")) } } // ViewModel中的实现 fun saveGameState(): GameStateBundle { return GameStateBundle( cells = _cells.value?.map { it.toBundle() }, mineCount = _mineCount.value, elapsedTime = _elapsedTime.value, gameState = _gameState.value ) } fun restoreGameState(bundle: GameStateBundle?) { bundle?.let { _cells.value = it.cells?.map { cellBundle -> Cell.fromBundle(cellBundle) } _mineCount.value = it.mineCount _elapsedTime.value = it.elapsedTime _gameState.value = it.gameState if (it.gameState == GameState.PLAYING) { startTimer() } } }5.2 性能优化技巧
- 视图回收优化:在自定义Adapter中重用convertView
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val cellView = convertView as? CellView ?: CellView(context).apply { layoutParams = GridView.LayoutParams(cellSize, cellSize) } val cell = getItem(position) cellView.updateState(cell) return cellView }- 减少对象创建:在游戏逻辑中使用基本类型而非对象
// 使用二维数组代替对象列表存储单元格状态 private val cellStates = Array(rows) { IntArray(columns) }- 异步计算:大型雷区生成放在后台线程
viewModelScope.launch(Dispatchers.Default) { val mines = generateMines(firstClickPos, mineCount, rows * columns) withContext(Dispatchers.Main) { updateBoard(mines) } }5.3 难度设置与自定义游戏
通过SharedPreferences保存用户偏好:
object GamePreferences { private const val PREFS_NAME = "minesweeper_prefs" private const val KEY_DIFFICULTY = "difficulty" private const val KEY_CUSTOM_ROWS = "custom_rows" private const val KEY_CUSTOM_COLS = "custom_cols" private const val KEY_CUSTOM_MINES = "custom_mines" fun saveDifficulty(context: Context, difficulty: Difficulty) { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit { putString(KEY_DIFFICULTY, difficulty.name) } } fun getDifficulty(context: Context): Difficulty { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return try { Difficulty.valueOf(prefs.getString(KEY_DIFFICULTY, Difficulty.MEDIUM.name)!!) } catch (e: Exception) { Difficulty.MEDIUM } } // 保存自定义设置... }5.4 动画与音效增强
为提升游戏体验,可以添加简单的动画和音效:
// 单元格翻转动画 private fun revealWithAnimation(position: Int) { val cellView = board_grid.getChildAt(position) as? CellView ?: return cellView.animate() .rotationY(90f) .setDuration(100) .withEndAction { cellView.updateState(cells.value[position]) cellView.rotationY = 270f cellView.animate() .rotationY(360f) .setDuration(100) .start() } .start() SoundManager.playRevealSound() } // 音效管理单例 object SoundManager { private var soundPool: SoundPool? = null private val soundMap = mutableMapOf<Int, Int>() fun init(context: Context) { soundPool = SoundPool.Builder() .setMaxStreams(3) .build() soundMap[R.raw.reveal_sound] = soundPool?.load(context, R.raw.reveal_sound, 1) ?: -1 // 加载其他音效... } fun playRevealSound() { soundMap[R.raw.reveal_sound]?.let { soundId -> soundPool?.play(soundId, 1f, 1f, 1, 0, 1f) } } }6. 测试与调试
6.1 单元测试关键算法
使用JUnit测试雷区生成算法:
class BoardGeneratorTest { private val generator = BoardGenerator() @Test fun `generateMines should not place mines near first click`() { val firstClick = 40 // 假设点击中间位置(4,4) in 9x9 val mines = generator.generateMines(firstClick, 10, 81) // 检查点击位置及其周围8格都没有雷 val adjacent = generator.getAdjacentPositions(firstClick, 9, 9) + firstClick assertTrue(adjacent.none { it in mines }) } @Test fun `calculateAdjacentMines should return correct count`() { val mines = setOf(0, 1, 2, 9, 11, 18, 19, 20) assertEquals(8, generator.calculateAdjacentMines(mines, 10, 9, 9)) // 中心位置 assertEquals(3, generator.calculateAdjacentMines(mines, 3, 9, 9)) // 边缘位置 } }6.2 UI测试关键交互
使用Espresso测试游戏流程:
@RunWith(AndroidJUnit4::class) class GameActivityTest { @get:Rule val activityRule = ActivityScenarioRule(GameActivity::class.java) @Test fun firstClickShouldBeSafe() { onView(withId(R.id.board_grid)) .perform(actionOnItemAtPosition(40, click())) // 点击中间位置 // 验证没有立即显示游戏结束 onView(withId(R.id.game_over_dialog)) .check(matches(not(isDisplayed()))) } @Test fun longClickShouldToggleFlag() { val position = 0 onView(withId(R.id.board_grid)) .perform(actionOnItemAtPosition(position, longClick())) // 验证该位置显示旗帜 onView(withId(R.id.board_grid)) .check(matches(atPosition(position, hasFlag()))) } } fun hasFlag(): Matcher<View> { return object : BoundedMatcher<View, CellView>(CellView::class.java) { override fun describeTo(description: Description) { description.appendText("has flag") } override fun matchesSafely(item: CellView): Boolean { return item.isFlagged } } }6.3 常见问题排查
GridView显示不全或滚动问题:
- 确保GridView的layout_height设置为match_parent或固定高度
- 检查单元格大小计算是否正确,避免因小数像素导致的布局问题
触摸事件不响应:
- 确认没有父视图拦截了触摸事件
- 检查自定义View的clickable和focusable属性设置
性能问题(大型雷区卡顿):
- 使用Android Profiler检查内存使用情况
- 确保Adapter的getView方法高效,重用convertView
- 考虑使用RecyclerView替代GridView以获得更好的性能
屏幕旋转导致游戏重置:
- 实现onSaveInstanceState正确保存游戏状态
- 在ViewModel中使用SavedStateHandle保持数据
音效延迟:
- 提前初始化SoundPool,不要等到第一次播放时才加载
- 考虑使用MediaPlayer替代SoundPool播放背景音乐
7. 项目扩展方向
7.1 多主题支持
通过资源替换实现不同视觉风格:
<!-- res/values/styles.xml --> <style name="Theme.Minesweeper" parent="Theme.Material3.DayNight"> <!-- 默认主题颜色 --> </style> <!-- res/values-night/styles.xml --> <style name="Theme.Minesweeper" parent="Theme.Material3.DayNight"> <!-- 暗色主题颜色 --> </style> <!-- res/values/attrs.xml --> <attr name="cellStyle" format="enum"> <enum name="classic" value="0" /> <enum name="modern" value="1" /> <enum name="pixel" value="2" /> </attr> <!-- 在自定义View中使用 --> val style = context.theme.obtainStyledAttributes(attrs, R.styleable.CellView, 0, 0) val cellStyle = try { style.getInt(R.styleable.CellView_cellStyle, 0) } finally { style.recycle() }7.2 在线排行榜
使用Firebase实现简单的分数排行榜:
class LeaderboardManager(private val context: Context) { private val firestore = Firebase.firestore private val leaderboardRef = firestore.collection("leaderboard") fun submitScore(name: String, time: Int, difficulty: Difficulty) { val score = hashMapOf( "name" to name, "time" to time, "difficulty" to difficulty.name, "timestamp" to FieldValue.serverTimestamp() ) leaderboardRef.add(score) .addOnSuccessListener { Toast.makeText(context, "Score submitted!", Toast.LENGTH_SHORT).show() } .addOnFailureListener { Toast.makeText(context, "Submission failed", Toast.LENGTH_SHORT).show() } } fun getTopScores(difficulty: Difficulty, callback: (List<ScoreEntry>) -> Unit) { leaderboardRef .whereEqualTo("difficulty", difficulty.name) .orderBy("time") .limit(10) .get() .addOnSuccessListener { result -> val scores = result.map { doc -> ScoreEntry( name = doc.getString("name") ?: "Anonymous", time = doc.getLong("time")?.toInt() ?: 0 ) } callback(scores) } } }7.3 成就系统
实现游戏内成就解锁功能:
class AchievementManager(private val context: Context) { private val prefs = context.getSharedPreferences("achievements", Context.MODE_PRIVATE) fun checkAchievements(gameStats: GameStats) { if (gameStats.totalGames >= 10 && !prefs.getBoolean("veteran", false)) { unlockAchievement("veteran", "Played 10 games") } if (gameStats.fastestTime < 30 && !prefs.getBoolean("speed_demon", false)) { unlockAchievement("speed_demon", "Complete a game in under 30 seconds") } // 其他成就检查... } private fun unlockAchievement(id: String, description: String) { prefs.edit().putBoolean(id, true).apply() showAchievementToast(description) } private fun showAchievementToast(description: String) { Toast.makeText(context, "Achievement Unlocked: $description", Toast.LENGTH_LONG).show() } }7.4 手势控制扩展
为平板设备添加更多手势支持:
class GameGestureListener(private val viewModel: GameViewModel) : GestureDetector.SimpleOnGestureListener() { override fun onDoubleTap(e: MotionEvent): Boolean { // 实现双击快速展开安全区域 val position = viewModel.getPositionFromCoordinates(e.x, e.y) if (position != -1) { viewModel.onCellDoubleClicked(position) } return true } override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { // 实现滑动切换难度 if (abs(velocityX) > 1000) { viewModel.switchDifficulty(if (velocityX > 0) NEXT else PREVIOUS) return true } return false } } // 在Activity中设置 private val gestureDetector = GestureDetector(this, GameGestureListener(viewModel)) override fun onTouchEvent(event: MotionEvent): Boolean { return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event) }8. 项目总结与经验分享
在完成这个Android扫雷游戏项目的过程中,有几个关键经验值得分享:
算法与UI分离的重要性:将游戏核心逻辑(如雷区生成、数字计算)与界面表现完全分离,使得后期更换UI框架(如迁移到Compose)变得非常容易。这种架构设计也大大简化了单元测试的编写。
性能优化的平衡:最初尝试使用自定义View绘制整个棋盘,虽然性能更好,但开发效率低下。最终选择GridView方案,在保证流畅度的前提下大幅提升了开发速度。对于9×9到30×16的标准尺寸,这种妥协是完全值得的。
测试驱动开发的实践:先为雷区生成算法编写测试用例,再实现具体代码,这种方式帮助发现了许多边界条件问题,比如首次点击在角落时的雷区生成逻辑。
状态管理的复杂性:游戏状态的保存与恢复看似简单,实际涉及大量细节。特别是计时器的暂停与恢复,需要仔细处理生命周期事件。最终采用ViewModel+SavedStateHandle的方案最为可靠。
交互细节的打磨:标准的点击和长按操作对于老玩家很自然,但对新手可能不够直观。添加简单的操作指引和动画反馈后,用户体验明显提升。
这个项目虽然不大,但涵盖了Android开发的多个重要方面:UI设计、事件处理、状态管理、数据持久化等。对于想要学习Android开发的同学,实现一个功能完整的扫雷游戏是非常好的练习项目。它既有足够的挑战性,又不会过于复杂导致难以完成。