2024-10-23 17:34:38
,某些文章具有时效性,若有错误或已失效,请在下方留言。如果你想继续学习本教程,请使用 App template
创建一个新的 macOS
项目,并将其命名为 Minesweeper
。
显示网格
我们的第一步将是显示一个包含棋盘整体布局的方格网格。这些方格中的所有内容都将暂时隐藏起来,所以看起来就像是在等待玩家走第一步棋。
首先创建一个新的 Swift
文件,名为 Square.swift
。这个文件将保存一个 score 的数据,我们要把它变成一个可观察的类,这样我们的用户界面就能对单个方块的状态变化做出响应。
将下面的代码放到 Square.swift
文件中
@Observable
class Square: Equatable, Identifiable {
var id = UUID()
var row: Int
var column: Int
var hasMine = false
var nearbyMines = 0
var isRevealed = false
var isFlagged = false
init(row: Int, column: Int) {
self.row = row
self.column = column
}
static func == (lhs: Square, rhs: Square) -> Bool {
lhs.id == rhs.id
}
}
在 ContentView
中,我们需要创建并存储一个二维方格数组,以表示游戏中的行和列:
@State private var rows = [[Square]]();
在创建网格内容时,我们首先要清除当前网格中的所有内容,这样以后就可以轻松开始新游戏,然后循环九行九列,将数组填满。
现在就将此方法添加到ContentView
中:
func createGrid() {
rows.removeAll()
for row in 0..<9 {
var rowSquares = [Square]()
for column in 0..<9 {
let square = Square(row: row, column: column)
rowSquares.append(square)
}
rows.append(rowSquares)
}
}
说到渲染所有这些方格,我们现在先从简单的开始。稍后,我们将需要显示被覆盖的方格、地雷、附近有什么的提示,以及玩家认为地雷可能在哪里的标记,因此尽管我们当前的版本很短,但我们仍将把它放到一个单独的视图中。
创建一个名为SquareView
的视图,并为其添加以下代码:
struct SquareView: View {
var square: Square
var body: some View {
Rectangle()
.fill(.gray.gradient)
.frame(width: 60, height: 60)
}
}
对于预览,只需传入一个正方形示例即可:
#Preview {
SquareView(square: Square(row: 0, column: 0))
}
最后,我们可以在ContentView
中汇集所有这些工作,它将使用我们的rows
数组渲染一大堆方块。这些方块还不能做任何事情,但至少可以让你看到游戏板。
用此代码替换ContentView
的body
属性:
Grid(horizontalSpacing: 2, verticalSpacing: 2) {
ForEach(0..<rows.count, id: \.self) { row in
GridRow {
ForEach(rows[row]) { square in
SquareView(square: square)
}
}
}
}
.font(.largeTitle)
.onAppear(perform: createGrid)
.preferredColorScheme(.dark)
现在运行应用程序–如果一切都按计划进行,你应该会看到一个由方格组成的网格,方格之间有分界线。

布设地雷
现在我们已经有了一个简单的游戏棋盘,下一步就是实际放置一些地雷。
这本身并不难,因为我们只需随机选取 10 个方格,然后将hasMine
设为 true
即可。然而,两个额外的要求让它变得更加复杂:
- 任何没有地雷的地方都需要说明附近有多少地雷,如果有的话。
- 不应该在棋手的第一步棋上或附近布设地雷,否则他们可能会立即输掉比赛。
要解决第一个问题,就需要实现一种方法,检索当前方格旁边的所有方格。这个方法并不难,只是比较重复:我们需要读取传入的任何方格的上方、下方、左侧和右侧的方格,并对斜向相邻的方格进行同样的读取。每一次读取都需要进行检查,以确保不会意外读取到边界之外。
因此,首先我们可以实现一个小方法,返回特定行和列上的正方形,如果位置无效,则返回nil
:
func square(atRow row: Int, column: Int) -> Square? {
if row < 0 { return nil }
if row >= rows.count { return nil }
if column < 0 { return nil }
if column >= rows[row].count { return nil }
return rows[row][column]
}
现在,我们可以从任意一行和任意一列调用它,以获得所有八个方向上的相邻方格:
func getAdjacentSquares(toRow row: Int, column: Int) -> [Square] {
var result = [Square?]()
// 左上方
result.append(square(atRow: row - 1, column: column - 1))
// 正上方
result.append(square(atRow: row - 1, column: column))
// 右上方
result.append(square(atRow: row - 1, column: column + 1))
// 左方
result.append(square(atRow: row, column: column - 1))
// 右方
result.append(square(atRow: row, column: column + 1))
// 左下方
result.append(square(atRow: row + 1, column: column - 1))
// 正下方
result.append(square(atRow: row + 1, column: column))
// 右下方
result.append(square(atRow: row + 1, column: column + 1))
return result.compactMap { $0 }
}
该方法在两个方面非常有用,首先是我们如何放置地雷。放置地雷实际上分为两个部分,所以我们先添加方法签名,然后再分解需要发生的事情–现在就把它放到ContentView
中:
func placeMines(avoiding: Square) {
// more code to come
}
avoiding
参数非常重要,因为该方法将在玩家第一次移动后被调用,这样我们就能确保在玩家选择的地方没有放置地雷。
我们首先要获取一个包含棋盘上所有方格的变量数组。通过调用flatMap()
可以读取所有行上的所有方格,但由于我们将在项目的其他几个部分中用到它,因此我们将专门为此创建一个新属性–现在就将其添加到ContentView
中:
以获取所有行中的所有方格作为一个数组:
var allSquares: [Square] {
rows.flatMap { $0 }
}
现在,我们可以在placeMines()
中获取一个变量副本
var possibleSquares = allSquares
然后,我们可以使用新的getAdjacentSquares()
方法来获取一个数组,其中包含了我们应该避免的所有方格,并确保添加了原始方格。
let disallowed = getAdjacentSquares(toRow: avoiding.row, column: avoiding.column) + CollectionOfOne(avoiding)
现在,我们可以从可能的方格中删除不允许的方格:
possibleSquares.removeAll(where: disabled.contains)
最后,我们可以对剩余的可能方格进行洗牌,使前 10 个方格都有地雷:
for square in possibleSquares.shuffled().prefix(10) {
square.hasMine = true
}
这样,方法的第一部分就完成了:我们现在有了 10 个随机放置在棋盘上的地雷。
该方法的第二部分需要更新所有其他方格,以便它们知道周围有多少地雷,这也是对 getAdjacentSquares()
的另一次调用。现在将其添加到 placeMines()
的末尾:
for row in rows {
for square in row {
let adjacentSquares = getAdjacentSquares(toRow: square.row, column: square.column)
square.nearbyMines = adjacentSquares.filter(\.hasMine).count
}
}
所有这些代码意味着我们现在有了一个真正的游戏棋盘,地雷和提示数字都放置正确。当然,你实际上看不出来,因为我们在SquareView
中显示的总是同一个固定的灰色方块,所以我们接下来要解决这个问题。
首先,我们要让每个方格在揭示后都变暗,这样玩家就能看到自己已经走过的棋步。这可以通过SquareView
中的一个计算属性来跟踪
var color: Color {
if square.isRevealed {
.gray.opacity(0.2)
} else {
.gray
}
}
现在,在这个视图的body
中,我们将显示一个带有这种颜色的矩形,上面叠加着各种不同的东西,取决于它是一个暴露的地雷、一个附近的地雷计数器、一面旗帜,还是什么都没有:
ZStack {
Rectangle()
.fill(color.gradient)
if square.isRevealed {
if square.hasMine {
Text("💥")
.font(.system(size: 48))
.shadow(color: .red, radius: 1)
} else if square.nearbyMines > 0 {
Text(String(square.nearbyMines))
}
} else if square.isFlagged {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.black, .yellow)
.shadow(color: .black, radius: 3)
}
}
.frame(width: 60, height: 60)
有了这些,我们现在就有了一个真正的游戏网格–所有的方格都已正确放置和渲染,地雷、旗帜和相邻提示都已准备好显示。
当然,我们实际上看不到,因为我们没有放置地雷,也没有显示任何方格。因此,为了测试起见,在createGrid()
的末尾添加这一行,以模拟玩家在中心方格走第一步棋:
placeMines(avoiding: rows[4][4])
现在在Square
类中,将isRevealed
的默认值改为true
,确保整个游戏板都会显示出来。

采取行动
现在我们的游戏棋盘都已正确设置,可以开始实际游戏了。在此之前,请将Square
类中的isRevealed
设为false
,然后删除我们添加到createGrid ( )
末尾的placeMines ()
调用。
为了简化这部分工作,我们将添加三个属性,以不同的方式过滤allSquares
,返回有多少个方格被揭示、有多少个方格被标记以及有多少个方格布设有地雷:
var revealedSquares: [Square] {
allSquares.filter(\.isRevealed)
}
var flaggedSquares: [Square] {
allSquares.filter(\.isFlagged)
}
var minedSquares: [Square] {
allSquares.filter(\.hasMine)
}
我们要添加的最后一项是用户发现的地雷数量,即实际地雷数量减去用户标记的方格数量:
var minesFound: Int {
max(0, minedSquares.count - flaggedSquares.count)
}
有了这些属性,就该实现游戏中最重要的方法了:揭示方格内容的方法。
让我们从方法名称开始,一步一步地实现它:
func reveal(_ square: Square) {
// more code to come
}
如果该方格已经被揭示,或者被标记为标记方格,我们就会放弃。因此,请将这些代码添加到 // more code to come
guard square.isRevealed == false else { return }
guard square.isFlagged == false else { return }
接下来,如果我们还在这里,就应该把这个方格标记为 “已揭示”:
square.isRevealed = true
现在是递归的时候了。你看,如果这个方格附近没有地雷,那么附近的其他方格也很有可能没有地雷,所以我们应该一次性将它们全部显示出来,而不是让用户反复点击。
因此,我们可以通过在所有相邻方格上递归调用自身来结束该方法:
if square.nearbyMines == 0 {
let adjacentSquares = getAdjacentSquares(toRow: square.row, col: square.column)
for adjacentSquare in adjacentSquares {
reveal(adjacentSquare)
}
}
现在我们可以揭示方块了,接下来要实现的方法是让用户选择一个方块。这将调用reveal()
来揭示各种方格,但它还需要负责结束游戏,如果这是玩家的第一步,还需要负责放置地雷。
添加如下的方法
func select(_ square: Square) {
guard square.isRevealed == false else { return }
guard square.isFlagged == false else { return }
// Place mines on first move.
if revealedSquares.count == 0 {
placeMines(avoiding: square)
}
if square.hasMine == false && square.nearbyMines == 0 {
// this square is empty - are there any other empty squares around?
reveal(square)
} else {
square.isRevealed = true
if square.hasMine {
// you lose
}
}
}
我们要添加的最后一个方法也是一种移动方法:当用户认为某个方格中含有地雷而想要标记该方格时。这只需切换isFlagged
属性即可
func flag(_ square: Square) {
guard square.isRevealed == false else { return }
square.isFlagged.toggle()
}
这就完成了在棋盘上显示方格所需的全部逻辑,因此我们可以在网格视图中使用轻点手势和长按手势将其连接起来,就像这样:
SquareView(square: square)
.onTapGesture {
select(square)
}
.onLongPressGesture {
flag(square)
}
如果您现在运行游戏,您会发现它的状态非常好–默认情况下,您可以隐藏一个完整的棋盘,并可以点击显示方格。

添加游戏状态
现在游戏终于可以正常运行了,我们可以开始添加一些状态来管理游戏,这样用户就可以看到自己的进度,也可以在某些时候重新开始游戏。
首先在枚举它自己的文件或 ContentView.swift
中添加这个新的枚举:
enum GameState {
case waiting, playing, won, lost
}
这些映射分别为 “等待开始”、”正在进行游戏”、”游戏结束,因为他们赢了 “和 “游戏结束,因为他们输了”。
现在,我们可以为ContentView
添加三个新的@State
属性:
- 一个用于跟踪当前游戏状态,默认为.waiting。
- 一个用于跟踪游戏开始后已经过去了多少秒。
- 还有一个用于跟踪他们当前是否正悬停在重启按钮上:
现在就把它们添加到ContentView
中:
@State private var gameState = GameState.waiting
@State private var secondsElapsed = 0
@State private var isHoveringOverRestart = false
悬浮的表情乍看之下可能有些奇怪,但这是对原版扫雷游戏的小小回溯,在游戏过程中,一个小表情符号会显示出不同的表情。
我们在这里也要做类似的事情,所以添加这个计算属性,将所有内容封装在一个地方:
var statusEmoji: String {
if isHoveringOverRestart {
"😯"
} else {
switch gameState {
case .waiting:
"🙂"
case .playing:
"🙂"
case .won:
"😎"
case .lost:
"😵"
}
}
}
在将所有这些新属性添加到用户界面之前,我还想添加最后一个方法:将游戏重置为默认设置的方法,这意味着清除secondsElapsed
,将gameState
设置为.waiting
,然后再次调用createGrid()
来清除网格。
现在就将此方法添加到ContentView
中:
func reset() {
secondsElapsed = 0
gameState = .waiting
createGrid()
}
好了,回到我们的用户界面。我们已经有了一个显示雷区的网格,但我想让你用一个VStack
把它包起来,这样我们就可以在它前面放置我们的游戏状态信息了。
因此,在网格周围添加一个VStack
,然后把这个放在网格之前:
HStack(spacing: 0) {
Text(minesFound.formatted(.number.precision(.integerLength(3))))
.fixedSize()
.padding(.horizontal, 6)
.foregroundStyle(.red.gradient)
Button(action: reset) {
Text(statusEmoji)
.padding(.horizontal, 6)
.background(.gray.opacity(0.5).gradient)
}
.onHover { hovering in
isHoveringOverRestart = hovering
}
.buttonStyle(.plain)
Text(secondsElapsed.formatted(.number.precision(.integerLength(3))))
.fixedSize()
.padding(.horizontal, 6)
.foregroundStyle(.red.gradient)
}
.monospacedDigit()
.font(.largeTitle)
.background(.black)
.clipShape(.rect(cornerRadius: 10))
.padding(.top)
提示:使用单倍行距数字可以保持常规字体,但确保数字在变化时不会移动。这样可以避免不断进行小的布局调整,否则会很分散注意力。
现在我们已经在顶部获得了游戏信息,我们可以在网格中添加两个额外的修饰符,让网格看起来更漂亮一些:
.clipShape(.rect(cornerRadius: 6))
.padding([.horizontal, .bottom])
我们新添加的secondsElapsed
属性需要不断递增,但仅限于游戏实际运行时。为此,我们可以先在ContentView
中添加一个新的计时器属性,该属性每秒触发一次:
@State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
现在,我们可以在VStack
上添加onReceive()
修改器,将时间加 1
,但前提是游戏当前处于激活状态,且玩家尚未超过 999
秒:
.onReceive(timer) { _ in
guard gameState == .playing else { return }
guard secondsElapsed < 999 else { return }
secondsElapsed += 1
}
这一步只需两行代码,第一行是确保用户不能选择任何方格,除非我们正在等待游戏开始或当前处于活动游戏中–将此添加到select()
的开头:
guard gameState == .waiting || gameState == .playing else { return }
然后,在同一方法中,当用户走第一步棋时,我们已经调用了placeMines(avoiding: square)
。我们还需要将gameState
更改为.playing
,这样计时器才会启动:
gameState = .playing

结束游戏
至此,游戏基本完成。事实上,剩下的工作就是优雅地结束游戏,这意味着将游戏状态适当地改为.won
或.lost
,然后显示某种用户界面,以便玩家在准备好时重新开始游戏。
我们可以知道玩家何时获胜,因为露出的方格数等于总方格数减去我们拥有的地雷数,因此我们可以将这一条件添加到新的checkForWin()
方法中,然后适当更改gameState
:
func checkForWin() {
if revealedSquares.count == allSquares.count - minedSquares.count {
withAnimation(.default.delay(0.25)) {
gameState = .won
}
}
}
我们希望每次用户选择方格时都调用该方法,因此请在select()
方法的末尾添加该方法:
checkForWin()
说到 “lose
”,我们在select()
中已经有了// you lose
注释,因此我们只需将其替换为设置gameState
的代码,然后退出方法,这样就不会再做任何更改了:
withAnimation(.default.delay(0.25)) {
gameState = .lost
}
return
从逻辑角度看,游戏到此结束,但我们还需要向用户显示发生了什么,并给他们重新开始的机会。这其实只是一个标签和一个按钮,但我们可以添加一些自定义样式,使其更加美观。
首先,创建一个名为GameOverView
的新 SwiftUI 视图,然后赋予它一个属性来存储游戏状态,并赋予它另一个属性来存储一个函数,以便在重启游戏时运行:
var state: GameState
var restart: () -> Void
在其body
属性中,我们将显示文本和按钮,并添加一些自定义样式,使其看起来更有趣一些:
VStack(spacing: 10) {
Group {
if state == .won {
Text("You win!")
} else {
Text("Bad luck!")
}
}
.textCase(.uppercase)
.font(.system(size: 60).weight(.black))
.fontDesign(.rounded)
.foregroundStyle(.white)
Button(action: restart) {
Text("Try Again")
.padding(.horizontal, 10)
.padding(.vertical, 5)
.foregroundStyle(.white)
.background(.blue.gradient)
.clipShape(.rect(cornerRadius: 10))
}
.font(.title)
.buttonStyle(.plain)
}
.transition(.scale(scale: 2).combined(with: .opacity))
.padding(.vertical)
.padding(.bottom, 5)
.frame(maxWidth: .infinity)
.background(.black.opacity(0.75).gradient)
如果您使用的是 Xcode 的预览,这样的内容足以让您了解它的外观:
#Preview {
GameOverView(state: .won, restart: {})
}
我们希望在游戏输赢时,新视图显示在主游戏上方。为此,我们要将VStack
包裹在ZStack
中,然后在其末尾添加以下内容:
if gameState == .won || gameState == .lost {
GameOverView(state: gameState) {
withAnimation {
reset()
}
}
}
我还建议在游戏结束时调暗并禁用主网格,这样用户就无法在我们的GameOverView
叠加层后面点击。
这意味着要在网格中添加一个修饰符:
.opacity(gameState == .waiting || gameState == .playing ? 1 : 0.5)
还有一个是给整个VStack
的
.disabled(gameState == .won || gameState == .lost)
就这样,我们的比赛结束了。

挑战
相邻数字添加颜色
创建AdjacentSquaresText
的 SwiftUI 视图,填写下方代码
import SwiftUI
struct AdjacentSquaresText: View {
var adjacentNumber: Int
var textColor: Color {
switch adjacentNumber {
case 1:
.blue
case 2:
.green
case 3:
.red
case 4:
.purple
default:
.white
}
}
var body: some View {
Text(String(adjacentNumber))
.foregroundStyle(textColor)
.fontWeight(.medium)
}
}
在 SquareView
中修改附近地雷数量的视图为
AdjacentSquaresText(adjacentNumber: square.nearbyMines)

LED 数字显示
修改地雷数目以及计时器字体的样式为 LED 字体样式显示。

添加字体
将 DS-DIGI.TTF
字体文件拖转到项目中。

引用字体
在 info.plist
中添加字体的引用,引用的 key 为 Fonts provided by application
,值为字体文件的名称(包含扩展名),如下所示

PostScript 名称
将字体安装到 macOS
系统,在字体册中找到字体,查看右侧的 PostScript
名称即可。

使用字体
使用.custom()
方法进行字体的使用,具体代码如下所示
HStack(spacing: 0) {
Text(minesFound.formatted(.number.precision(.integerLength(3))))
.font(.custom("DS-Digital", size: 28)) // DS-Digital 是 PostScript 名称
.fixedSize()
.padding(.horizontal, 6)
.foregroundStyle(.red.gradient)
.frame(width: 56, alignment: .center)
Button(action: reset) {
Text(statusEmoji)
.padding(.horizontal, 6)
.background(.gray.opacity(0.5).gradient)
}
.onHover { hovering in
isHoveringOverRestart = hovering
}
.buttonStyle(.plain)
Text(secondsElasped.formatted(.number.precision(.integerLength(3))))
.font(.custom("DS-Digital", size: 28))
.fixedSize()
.padding(.horizontal, 6)
.foregroundStyle(.red.gradient)
.frame(width: 56, alignment: .center)
}
选择难度
简单: 9×9 方格, 总共 10 个雷;
困难: 16×16方格,总共 40 个雷
专家: 30×16 方格, 总共 99 个雷
创建游戏模式 GameMode.swift 文件,文件的内容如下所示
import Foundation
@Observable
class GameMode {
let rows: Int
let colums: Int
let mines: Int
init(rows: Int, colums: Int, mines: Int) {
self.rows = rows
self.colums = colums
self.mines = mines
}
static func currentGameMode(type: String) -> GameMode {
switch type {
case "简单":
GameMode(rows: 9, colums: 9, mines: 10)
case "困难":
GameMode(rows: 16, colums: 16, mines: 40)
case "专家":
GameMode(rows: 16, colums: 30, mines: 99)
default:
GameMode(rows: 100, colums: 100, mines: 100)
}
}
}
在 ContentView.swift
中,添加如下属性
@State private var currentGameModeType = "简单"
let gameModeTypes = ["简单", "困难", "专家"]
var currentMode: GameMode {
GameMode.currentGameMode(type: currentGameModeType)
}
在 body
属性中的 HStack
中添加 picker
组件
Picker(selection: $currentGameModeType) {
ForEach(gameModeTypes, id: \.self) {
Text($0)
}
} label: {
}
.onChange(of: currentGameModeType, createGrid)
.frame(width: 80)
.padding(.leading, 20)
修改 createGrid
方法
func createGrid() {
rows.removeAll()
for row in 0 ..< currentMode.rows {
var rowSquares = [Square]()
for column in 0 ..< currentMode.colums {
let square = Square(row: row, column: column)
rowSquares.append(square)
}
rows.append(rowSquares)
}
}
修改 placeMines
中地雷的数量
for square in possibleSquares.shuffled().prefix(currentMode.mines) {
square.hasMine = true
}
游戏模式的切换效果,如下图所示

添加音效
项目使用如上的依赖进行声音的播放。引入新的属性来追踪是否需要显示音效。
@State private var hasSound = true
在最外层的 HStack
的最后添加控制是否播放声音的按钮。
Button {
hasSound.toggle()
} label: {
Image(systemName: hasSound ? "speaker.wave.2.fill" : "speaker.slash.fill")
.frame(width: 20)
}
.padding(.trailing, 20)
在 createGrid
添加游戏开始的音效
if hasSound {
play(sound: "start.wav")
}
在select
开始的地方,添加点击音效,在触碰到地雷的时候,添加爆炸的音效。
if hasSound {
play(sound: "click.aiff")
}
if square.hasMine {
withAnimation(.default.delay(0.25)) {
if hasSound {
play(sound: "boom.wav")
}
gameState = .lost
}
return
}
在标记地雷的时候,添加旗帜音效
if hasSound {
play(sound: "flag.aiff")
}
在 checkForWin
方法的动画中添加游戏胜利的音效
if hasSound {
play(sound: "win.wav")
}
效果如下所示
参考
- 文章来自: Hacking With Swift Plus ↩
暂无评论内容