Minesweeper

温馨提示:本文最后更新于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数组渲染一大堆方块。这些方块还不能做任何事情,但至少可以让你看到游戏板。

用此代码替换ContentViewbody属性:

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 即可。然而,两个额外的要求让它变得更加复杂:

  1. 任何没有地雷的地方都需要说明附近有多少地雷,如果有的话。
  2. 不应该在棋手的第一步棋上或附近布设地雷,否则他们可能会立即输掉比赛。

要解决第一个问题,就需要实现一种方法,检索当前方格旁边的所有方格。这个方法并不难,只是比较重复:我们需要读取传入的任何方格的上方、下方、左侧和右侧的方格,并对斜向相邻的方格进行同样的读取。每一次读取都需要进行检查,以确保不会意外读取到边界之外。

因此,首先我们可以实现一个小方法,返回特定行和列上的正方形,如果位置无效,则返回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 字体样式显示。

LED 字体显示

添加字体

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

字体文件添加到项目

引用字体

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

info.plist文件引用字体

PostScript 名称

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

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
}

游戏模式的切换效果,如下图所示

不同的游戏模式

添加音效

音效
访问密码
1919

项目使用如上的依赖进行声音的播放。引入新的属性来追踪是否需要显示音效。

@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")
}

效果如下所示

 

参考

  1. 文章来自: Hacking With Swift Plus
© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容