KMP 实现鸿蒙跨端:Kotlin 小游戏 - 扫雷游戏
本文档介绍了如何在 Kotlin Multiplatform(KMP)鸿蒙跨端开发中实现经典扫雷游戏。该游戏具备经典玩法,包含 5×5 网格(含 5 个地雷),玩家点击格子时,若为地雷则游戏结束,若为安全格子则显示周围地雷数,计分规则为安全点击 + 10 分、踩中地雷 - 50 分,评价依踩中地雷数分为完美、不错、继续加油三个等级。核心功能包括网格初始化(随机分布地雷)、邻域计算函数(计算指定格子

目录
概述
本文档介绍如何在 Kotlin Multiplatform (KMP) 鸿蒙跨端开发中实现一个经典的扫雷游戏。这个案例展示了如何使用 Kotlin 的二维网格处理、邻域计算和游戏逻辑来创建一个完整的策略游戏系统。通过 KMP,这个游戏可以无缝编译到 JavaScript,在 OpenHarmony 应用中运行。
游戏的特点
- 经典玩法:传统的扫雷游戏规则
- 网格处理:实现二维网格的坐标转换
- 邻域计算:计算周围地雷数量
- 跨端兼容:一份 Kotlin 代码可同时服务多个平台
- 详细统计:显示安全率、得分和评价
游戏规则
基本规则
- 游戏网格:
- 5×5 的网格(25 个格子)
- 其中 5 个格子有地雷
- 其他 20 个格子是安全的
- 游戏流程:
- 玩家点击格子
- 如果是地雷,游戏结束
- 如果是安全格子,显示周围地雷数
- 继续点击直到游戏结束
- 计分规则:
- 安全点击:+10 分
- 踩中地雷:-50 分
- 评价标准:
- 完美:没有踩中任何地雷
- 不错:只踩中 1-2 个地雷
- 继续加油:踩中 3 个或以上地雷
游戏流程图
开始游戏
↓
显示 5×5 网格
↓
玩家点击格子
↓
检查格子内容
├→ 地雷 → 游戏结束,显示结果
└→ 安全 → 显示周围地雷数
↓
继续点击或游戏结束
↓
统计安全率和得分
↓
显示最终评价
核心功能
1. 网格初始化
val gridSize = 5
val totalCells = gridSize * gridSize
val mines = MutableList(totalCells) { false }
// 放置地雷
for (i in 0 until mineCount) {
mines[i] = true
}
mines.shuffle()
代码说明:
这段代码展示了如何初始化扫雷游戏的网格。定义网格大小为 5,总格子数为 25。创建一个可变列表,所有元素初始化为 false(表示无地雷)。然后在前 mineCount 个位置放置地雷(设置为 true)。最后使用 shuffle() 方法随机打乱列表,使地雷分布随机。这样就创建了一个随机的地雷分布。
2. 邻域计算函数
fun countAdjacentMines(index: Int): Int {
val row = index / gridSize
val col = index % gridSize
var count = 0
for (dr in -1..1) {
for (dc in -1..1) {
if (dr == 0 && dc == 0) continue
val newRow = row + dr
val newCol = col + dc
if (newRow in 0 until gridSize && newCol in 0 until gridSize) {
val newIndex = newRow * gridSize + newCol
if (mines[newIndex]) count++
}
}
}
return count
}
代码说明:
这个函数计算指定格子周围的地雷数量。首先将一维索引转换为二维坐标:行 = 索引 / 网格大小,列 = 索引 % 网格大小。然后使用嵌套循环遍历周围 8 个格子(dr 和 dc 都在 -1 到 1 之间)。跳过中心格子(dr == 0 && dc == 0)。对于每个邻域格子,检查其坐标是否在网格范围内,然后检查该位置是否有地雷。最后返回周围地雷的总数。
3. 点击检查函数
val checkCell: (Int) -> String = { index ->
if (mines[index]) {
"💣 地雷"
} else {
val adjacent = countAdjacentMines(index)
if (adjacent == 0) "⬜ 空" else "🔢 $adjacent"
}
}
代码说明:
这是一个 Lambda 函数,用于检查点击的格子内容。函数接收一个索引参数,返回一个字符串。首先检查该位置是否有地雷,如果有返回"💣 地雷"。如果没有地雷,计算周围地雷数量。如果周围没有地雷返回"⬜ 空",否则返回"🔢 "加上地雷数量。这个函数用于确定玩家点击后的结果。
4. 格式化函数
val formatClick: (Int, Int, String) -> String = { clickNum, index, result ->
val row = index / gridSize
val col = index % gridSize
val icon = when {
result.contains("💣") -> "❌"
result.contains("空") -> "✅"
else -> "ℹ️"
}
"$icon 第${clickNum}步: 点击[$row,$col] → $result"
}
代码说明:
这是一个 Lambda 函数,用于格式化点击操作为易读的字符串。接收三个参数:点击次数、格子索引和检查结果。首先将索引转换为行列坐标。然后根据结果选择相应的图标:地雷显示"❌",空格显示"✅",其他显示"ℹ️"。最后返回格式化的字符串,包含图标、点击次数、坐标和结果。这个函数用于生成游戏过程的日志。
实战案例
案例:完整的扫雷游戏
Kotlin 源代码
@OptIn(ExperimentalJsExport::class)
@JsExport
fun minesweeperGame(): String {
// 定义游戏网格大小
val gridSize = 5
val totalCells = gridSize * gridSize
val mineCount = 5
// 创建游戏网格(true表示有地雷)
val mines = MutableList(totalCells) { false }
// 放置地雷
for (i in 0 until mineCount) {
mines[i] = true
}
mines.shuffle()
// 计算每个格子周围的地雷数
fun countAdjacentMines(index: Int): Int {
val row = index / gridSize
val col = index % gridSize
var count = 0
for (dr in -1..1) {
for (dc in -1..1) {
if (dr == 0 && dc == 0) continue
val newRow = row + dr
val newCol = col + dc
if (newRow in 0 until gridSize && newCol in 0 until gridSize) {
val newIndex = newRow * gridSize + newCol
if (mines[newIndex]) count++
}
}
}
return count
}
// 模拟玩家的点击操作
val playerClicks = listOf(0, 1, 2, 5, 6, 7, 10, 11, 12, 15, 16, 17, 20, 21, 22)
// 定义点击结果函数
val checkCell: (Int) -> String = { index ->
if (mines[index]) {
"💣 地雷"
} else {
val adjacent = countAdjacentMines(index)
if (adjacent == 0) "⬜ 空" else "🔢 $adjacent"
}
}
// 定义格式化函数
val formatClick: (Int, Int, String) -> String = { clickNum, index, result ->
val row = index / gridSize
val col = index % gridSize
val icon = when {
result.contains("💣") -> "❌"
result.contains("空") -> "✅"
else -> "ℹ️"
}
"$icon 第${clickNum}步: 点击[$row,$col] → $result"
}
// 计算游戏结果
val clicks = playerClicks.mapIndexed { index, cellIndex ->
val result = checkCell(cellIndex)
formatClick(index + 1, cellIndex, result)
}
// 统计结果
val safeClicks = clicks.count { it.contains("✅") || it.contains("ℹ️") }
val mineHits = clicks.count { it.contains("❌") }
val score = (safeClicks * 10) - (mineHits * 50)
val winRate = (safeClicks * 100) / playerClicks.size
return "🎮 扫雷游戏\n" +
"━━━━━━━━━━━━━━━━━━━━━\n" +
"网格大小: ${gridSize}×${gridSize}\n" +
"总格子数: $totalCells\n" +
"地雷数量: $mineCount\n\n" +
"游戏过程:\n" +
clicks.joinToString("\n") + "\n\n" +
"━━━━━━━━━━━━━━━━━━━━━\n" +
"统计数据:\n" +
"安全点击: $safeClicks 次\n" +
"踩中地雷: $mineHits 次\n" +
"安全率: $winRate%\n\n" +
"得分计算:\n" +
"安全点击: $safeClicks × 10 = ${safeClicks * 10}\n" +
"地雷扣分: $mineHits × 50 = ${mineHits * 50}\n" +
"最终得分: $score 分\n\n" +
"最终结果: " + when {
mineHits == 0 -> "🏆 完美!没有踩中任何地雷!"
mineHits <= 2 -> "🎉 不错!只踩中 $mineHits 个地雷"
else -> "💪 继续加油!"
}
}
代码说明:
这是扫雷游戏的完整 Kotlin 实现。函数使用 @JsExport 装饰器将其导出为 JavaScript 可调用的函数。首先定义网格大小、总格子数和地雷数量。创建地雷列表并随机分布。定义邻域计算函数、点击检查函数和格式化函数。模拟玩家的点击操作序列。使用 mapIndexed 遍历每个点击,调用检查函数获取结果,然后格式化输出。统计安全点击数和踩中地雷数,计算得分和安全率。最后根据踩中地雷数确定游戏评价,返回完整的游戏过程和统计数据。
编译后的 JavaScript 代码
function minesweeperGame() {
// 定义游戏网格大小
var gridSize = 5;
var totalCells = gridSize * gridSize;
var mineCount = 5;
// 创建游戏网格
var mines = [];
for (var i = 0; i < totalCells; i++) {
mines[i] = false;
}
for (var i = 0; i < mineCount; i++) {
mines[i] = true;
}
// 随机打乱
for (var i = mines.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = mines[i];
mines[i] = mines[j];
mines[j] = temp;
}
// 计算每个格子周围的地雷数
function countAdjacentMines(index) {
var row = Math.floor(index / gridSize);
var col = index % gridSize;
var count = 0;
for (var dr = -1; dr <= 1; dr++) {
for (var dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
var newRow = row + dr;
var newCol = col + dc;
if (newRow >= 0 && newRow < gridSize && newCol >= 0 && newCol < gridSize) {
var newIndex = newRow * gridSize + newCol;
if (mines[newIndex]) count++;
}
}
}
return count;
}
// 模拟玩家的点击操作
var playerClicks = [0, 1, 2, 5, 6, 7, 10, 11, 12, 15, 16, 17, 20, 21, 22];
// 定义点击结果函数
var checkCell = function(index) {
if (mines[index]) {
return '💣 地雷';
} else {
var adjacent = countAdjacentMines(index);
return adjacent === 0 ? '⬜ 空' : '🔢 ' + adjacent;
}
};
// 定义格式化函数
var formatClick = function(clickNum, index, result) {
var row = Math.floor(index / gridSize);
var col = index % gridSize;
var icon;
if (result.indexOf('💣') !== -1) {
icon = '❌';
} else if (result.indexOf('空') !== -1) {
icon = '✅';
} else {
icon = 'ℹ️';
}
return icon + ' 第' + clickNum + '步: 点击[' + row + ',' + col + '] → ' + result;
};
// 计算游戏结果
var clicks = [];
for (var i = 0; i < playerClicks.length; i++) {
var cellIndex = playerClicks[i];
var result = checkCell(cellIndex);
clicks.push(formatClick(i + 1, cellIndex, result));
}
// 统计结果
var safeClicks = 0, mineHits = 0;
for (var i = 0; i < clicks.length; i++) {
if (clicks[i].indexOf('✅') !== -1 || clicks[i].indexOf('ℹ️') !== -1) {
safeClicks++;
} else {
mineHits++;
}
}
var score = (safeClicks * 10) - (mineHits * 50);
var winRate = Math.floor((safeClicks * 100) / playerClicks.length);
// 确定最终结果
var finalResult;
if (mineHits === 0) {
finalResult = '🏆 完美!没有踩中任何地雷!';
} else if (mineHits <= 2) {
finalResult = '🎉 不错!只踩中 ' + mineHits + ' 个地雷';
} else {
finalResult = '💪 继续加油!';
}
return '🎮 扫雷游戏\n' +
'━━━━━━━━━━━━━━━━━━━━━\n' +
'网格大小: ' + gridSize + '×' + gridSize + '\n' +
'总格子数: ' + totalCells + '\n' +
'地雷数量: ' + mineCount + '\n\n' +
'游戏过程:\n' +
clicks.join('\n') + '\n\n' +
'━━━━━━━━━━━━━━━━━━━━━\n' +
'统计数据:\n' +
'安全点击: ' + safeClicks + ' 次\n' +
'踩中地雷: ' + mineHits + ' 次\n' +
'安全率: ' + winRate + '%\n\n' +
'得分计算:\n' +
'安全点击: ' + safeClicks + ' × 10 = ' + (safeClicks * 10) + '\n' +
'地雷扣分: ' + mineHits + ' × 50 = ' + (mineHits * 50) + '\n' +
'最终得分: ' + score + ' 分\n\n' +
'最终结果: ' + finalResult;
}
代码说明:
这是 Kotlin 代码编译到 JavaScript 后的结果。可以看到 Kotlin 的语言特性被转换为 JavaScript 等价物:MutableList 变成数组,shuffle() 变成 Fisher-Yates 随机排序算法,in 操作符变成比较操作符,mapIndexed 变成带索引的 for 循环。虽然编译后的代码看起来不同,但它保留了原始 Kotlin 代码的逻辑。使用 ES Module 格式,可以被其他模块导入。包含完整的类型定义(.d.ts 文件),提供 TypeScript 支持。可以直接在浏览器或 Node.js 中运行。
ArkTS 调用代码
import { minesweeperGame } from './hellokjs';
@Entry
@Component
struct Index {
@State message: string = '加载中...';
@State results: string[] = [];
@State caseTitle: string = '小游戏 - 扫雷游戏';
aboutToAppear(): void {
this.loadResults();
}
loadResults(): void {
try {
// 调用 Kotlin 编译的 JavaScript 函数
const gameResult = minesweeperGame();
this.results = [gameResult];
this.message = '✓ 游戏已加载';
} catch (error) {
this.message = `✗ 错误: ${error}`;
}
}
build() {
Column() {
// 顶部标题栏
Row() {
Text('KMP 鸿蒙跨端')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Spacer()
Text('Kotlin 案例')
.fontSize(14)
.fontColor(Color.White)
}
.width('100%')
.height(50)
.backgroundColor('#3b82f6')
.padding({ left: 20, right: 20 })
.alignItems(VerticalAlign.Center)
.justifyContent(FlexAlign.SpaceBetween)
// 案例标题
Column() {
Text(this.caseTitle)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1f2937')
Text(this.message)
.fontSize(13)
.fontColor('#6b7280')
.margin({ top: 5 })
}
.width('100%')
.padding({ left: 20, right: 20, top: 20, bottom: 15 })
.alignItems(HorizontalAlign.Start)
// 结果显示区域
Scroll() {
Column() {
ForEach(this.results, (result: string) => {
Column() {
Text(result)
.fontSize(13)
.fontFamily('monospace')
.fontColor('#374151')
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.border({ width: 1, color: '#e5e7eb' })
.borderRadius(8)
.margin({ bottom: 12 })
})
}
.width('100%')
.padding({ left: 16, right: 16 })
}
.layoutWeight(1)
.width('100%')
// 底部按钮区域
Row() {
Button('刷新')
.width('48%')
.height(44)
.backgroundColor('#3b82f6')
.fontColor(Color.White)
.fontSize(14)
.onClick(() => {
this.loadResults();
})
Button('返回')
.width('48%')
.height(44)
.backgroundColor('#6b7280')
.fontColor(Color.White)
.fontSize(14)
.onClick(() => {
// 返回操作
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.width('100%')
.height('100%')
.backgroundColor('#f9fafb')
}
}
代码说明:
这是 OpenHarmony ArkTS 页面的完整实现,展示了如何集成和调用 Kotlin 编译生成的游戏函数。首先通过 import 语句从 ./hellokjs 模块导入 minesweeperGame 函数。页面使用 @Entry 和 @Component 装饰器定义为可入口的组件。定义了三个响应式状态变量:message 显示操作状态,results 存储游戏结果,caseTitle 显示标题。aboutToAppear() 生命周期钩子在页面加载时调用 loadResults() 进行初始化。loadResults() 方法调用 Kotlin 函数进行游戏,将结果存储在 results 数组中,并更新 message 显示状态。使用 try-catch 块捕获异常。build() 方法定义了完整的 UI 布局,包括顶部标题栏、游戏标题、结果显示区域和底部按钮区域。使用 monospace 字体显示游戏过程,保持格式对齐。
编译过程详解
Kotlin 到 JavaScript 的转换
| Kotlin 特性 | JavaScript 等价物 |
|---|---|
| MutableList | 数组 [] |
| shuffle() | 随机排序 |
| 嵌套循环 | 嵌套 for 循环 |
| 范围检查 (in) | 比较操作符 |
| mapIndexed() | 带索引的数组循环 |
关键转换点
- 网格表示:一维数组表示二维网格
- 坐标转换:索引与行列的相互转换
- 邻域遍历:嵌套循环遍历周围 8 个格子
- 集合操作:转换为数组操作
游戏扩展
扩展 1:增加难度级别
// 简单:3×3 网格,2 个地雷
val easyGrid = Triple(3, 9, 2)
// 普通:5×5 网格,5 个地雷
val normalGrid = Triple(5, 25, 5)
// 困难:7×7 网格,10 个地雷
val hardGrid = Triple(7, 49, 10)
代码说明:
这段代码展示了如何为游戏添加难度级别。使用 Triple 数据类存储三个参数:网格大小、总格子数和地雷数量。简单难度使用 3×3 网格,2 个地雷。普通难度使用 5×5 网格,5 个地雷。困难难度使用 7×7 网格,10 个地雷。这允许玩家根据自己的技能水平选择合适的难度。
扩展 2:添加标记功能
val flaggedCells = mutableSetOf<Int>()
fun toggleFlag(index: Int) {
if (flaggedCells.contains(index)) {
flaggedCells.remove(index)
} else {
flaggedCells.add(index)
}
}
代码说明:
这段代码展示了如何实现标记/取消标记功能。使用 mutableSetOf 创建一个可变集合存储已标记的格子索引。toggleFlag() 函数用于切换格子的标记状态。如果格子已标记,则移除标记;如果未标记,则添加标记。这允许玩家标记他们认为是地雷的格子,帮助策略规划。
扩展 3:添加计时功能
val startTime = System.currentTimeMillis()
// ... 游戏逻辑 ...
val endTime = System.currentTimeMillis()
val timeUsed = (endTime - startTime) / 1000
代码说明:
这段代码展示了如何添加计时功能。使用 System.currentTimeMillis() 获取当前时间戳(毫秒)。在游戏开始时记录开始时间,在游戏结束时记录结束时间。计算时间差并除以 1000 转换为秒。这允许统计玩家完成游戏所用的时间,用于排行榜或性能评估。
扩展 4:添加自动展开
fun revealEmpty(index: Int, revealed: MutableSet<Int>) {
if (revealed.contains(index)) return
revealed.add(index)
if (countAdjacentMines(index) == 0) {
// 递归展开周围格子
for (neighbor in getNeighbors(index)) {
revealEmpty(neighbor, revealed)
}
}
}
代码说明:
这段代码展示了如何实现自动展开功能。当玩家点击一个周围没有地雷的格子时,自动展开所有相邻的空格子。首先检查该格子是否已展开,如果是则返回。然后将格子添加到已展开集合。如果该格子周围没有地雷,递归调用 revealEmpty() 展开所有邻域格子。这是扫雷游戏中的标准功能,大大提高了游戏体验。
最佳实践
1. 使用坐标转换函数
// ✅ 好:提取为函数
fun indexToCoord(index: Int, gridSize: Int): Pair<Int, Int> {
return Pair(index / gridSize, index % gridSize)
}
// ❌ 不好:重复计算
val row = index / gridSize
val col = index % gridSize
代码说明:
这个示例对比了两种处理坐标转换的方法。第一种方法将坐标转换逻辑提取为独立的函数,提高了代码的可读性和可重用性。第二种方法在多个地方重复相同的计算,容易出错且难以维护。最佳实践是:将常用的计算逻辑提取为函数。
2. 使用范围检查
// ✅ 好:使用 in 操作符
if (newRow in 0 until gridSize && newCol in 0 until gridSize)
// ❌ 不好:多个比较
if (newRow >= 0 && newRow < gridSize && newCol >= 0 && newCol < gridSize)
代码说明:
这个示例对比了两种范围检查的方法。第一种方法使用 in 操作符和范围表达式,代码简洁易读。第二种方法使用多个比较操作符,代码冗长且容易出错。最佳实践是:使用 in 操作符进行范围检查。
3. 使用 mapIndexed()
// ✅ 好:使用 mapIndexed()
val clicks = playerClicks.mapIndexed { index, cellIndex -> /* ... */ }
// ❌ 不好:使用 for 循环
for (i in playerClicks.indices) {
val cellIndex = playerClicks[i]
}
代码说明:
这个示例对比了两种处理带索引列表的方法。第一种方法使用 mapIndexed(),直接在 Lambda 中获得索引和元素,代码简洁。第二种方法使用 for 循环和索引访问,代码冗长。最佳实践是:当需要索引和元素时,使用 mapIndexed()。
4. 提取复杂逻辑
// ✅ 好:提取为函数
fun countAdjacentMines(index: Int): Int { /* ... */ }
// ❌ 不好:内联复杂逻辑
val adjacent = /* 复杂的计算 */
代码说明:
这个示例对比了两种处理复杂逻辑的方法。第一种方法将邻域地雷计算逻辑提取为独立的函数,提高了代码的可读性和可维护性。第二种方法将复杂逻辑内联在主代码中,使代码难以理解和维护。最佳实践是:将复杂的逻辑提取为独立的函数。
常见问题
Q1: 如何实现自动展开空白区域?
A: 使用递归或队列实现:
fun revealEmpty(index: Int, revealed: MutableSet<Int>) {
if (revealed.contains(index) || mines[index]) return
revealed.add(index)
if (countAdjacentMines(index) == 0) {
for (neighbor in getNeighbors(index)) {
revealEmpty(neighbor, revealed)
}
}
}
代码说明:
这段代码展示了如何实现自动展开功能。首先检查格子是否已展开或包含地雷,如果是则返回。然后将格子添加到已展开集合。如果该格子周围没有地雷,递归展开所有邻域格子。这是扫雷游戏的标准功能,大大提高了游戏体验。
Q2: 如何处理游戏结束条件?
A: 检查是否踩中地雷或全部安全格子都被揭示:
val gameOver = mineHits > 0 || safeClicks == totalCells - mineCount
代码说明:
这段代码展示了如何判断游戏是否结束。游戏结束有两种情况:踩中地雷(mineHits > 0)或所有安全格子都被揭示(safeClicks == totalCells - mineCount)。这个条件用于控制游戏循环的结束。
Q3: 如何实现难度选择?
A: 使用参数化函数:
fun minesweeperGame(difficulty: String): String {
val (gridSize, mineCount) = when (difficulty) {
"easy" -> Pair(3, 2)
"normal" -> Pair(5, 5)
"hard" -> Pair(7, 10)
else -> Pair(5, 5)
}
// ... 游戏逻辑 ...
}
代码说明:
这段代码展示了如何实现难度选择。函数接收一个难度参数字符串。使用 when 表达式根据难度返回不同的网格大小和地雷数量。使用解构赋值同时获得两个值。这允许玩家选择合适的难度级别。
Q4: 如何优化性能?
A:
- 预计算邻域地雷数
- 使用位操作表示网格状态
- 缓存计算结果
Q5: 如何实现网络对战?
A: 使用 WebSocket 同步游戏状态:
external class WebSocket(url: String) {
fun send(data: String)
var onmessage: ((String) -> Unit)?
}
总结
关键要点
- ✅ 使用一维数组表示二维网格
- ✅ 实现坐标转换函数
- ✅ 计算邻域地雷数量
- ✅ 使用 mapIndexed() 处理操作
- ✅ KMP 能无缝编译到 JavaScript
更多推荐



所有评论(0)