Atelier Clockwork

Building Minesweeper 3

Building a Board

Now that the game logic is in place, I'm ready to build a game board. Since there's a new layout system to work with, I'm going to use a Grid. The first thing that I need to do is to make an Identifiable type that stores a row worth of grid cells, and expose it in the GameService:

struct GridPointRow: Hashable, Identifiable {
    var id: Int { row }
    let row: Int
    let points: [GridPoint]

    init(row: Int, width: Int) {
        self.row = row
        points = (0..<width).map { column in
            GridPoint(x: column, y: row)
        }
    }
}

class GameService: ObservableObject {
    var gridRows: [GridPointRow] {
        (0..<height).map { row in
            GridPointRow(row: row, width: width)
        }
    }
}

Since GridRow is already Identifiable, this puts everything in place to be able to put together the board. Since I've done work to put the game logic together in other places, the board view is very simple:

struct GameView: View {
    @EnvironmentObject var gameService: GameService

    var body: some View {
        Grid(horizontalSpacing: 1, verticalSpacing: 1) {
            ForEach(gameService.gridRows) { row in
                GridRow {
                    ForEach(row.points) { point in
                        Cell(gridPoint: point, gameService: gameService)
                    }
                }
            }
        }
        .padding(1)
        .background(Theme.gameBackground)
        .disabled(gameService.state.isDisabled)
    }
}

private extension Cell {
    @MainActor
    init(gridPoint: GridPoint, gameService: GameService) {
        let value = gameService[gridPoint]

        self = Cell(value: value) {
            if case .revealed = value {
                gameService.revealSurroundingIfSafe(gridPoint)
            } else {
                gameService.reveal(gridPoint)
            }
        } flag: {
            gameService.toggleFlag(gridPoint)
        }
    }
}

private extension Game.State {
    var isDisabled: Bool {
        switch self {
        case .lose, .win:
            return true
        case .active:
            return false
        }
    }
}

This creates a grid with a 1px border and 1px line between all of the cells, so the next thing to do is to create the cell. for the first pass implementation, I created a simple version of the view that works on iOS and macOS, with the intention of integrating the ability to right click later. The body switches when the view is disabled to avoid having the button style change the opacity of the content when disabled. I've also split the creation of the content and colors out of this view so that when we switch based on the OS, there won't be duplicated code:

struct Cell: View {
   let value: Game.SpaceState
   let click: () -> Void
   let flag: () -> Void

   var body: some View {
       if isDisabled {
           buttonContent
       } else {
           Button(action: click) {
               buttonContent
           }
           .foregroundColor(value.foregroundColor)
           .highPriorityGesture(flagGesture)
           .buttonStyle(.plain)
       }
   }
}

private extension Cell {
   var buttonContent: some View {
       value.content
           .frame(width: 30, height: 30)
           .background { value.fillColor }
           .foregroundColor(value.foregroundColor)
   }

   var isDisabled: Bool {
       switch value {
       case .revealed(let count):
           return count == 0
       case .flaggedMine,
               .incorrectFlag,
               .unrevealedMine,
               .revealedMine:
           return true
       case .unrevealed, .flagged:
           return false
       }
   }

   var flagGesture: some Gesture {
       LongPressGesture()
           .onEnded { _ in
               flag()
           }
   }
}
extension Game.SpaceState {
@ViewBuilder var content: some View {
    switch self {
    case .unrevealed:
        Color.clear
    case .revealedMine:
        Image(systemName: "xmark.seal.fill")
    case .flagged:
        Image(systemName: "flag")
    case .revealed(let count):
        if count > 0 {
            Text(count, format: .number)
                .fontWeight(.bold)
        } else {
            Color.clear
        }
    case .flaggedMine:
        Image(systemName: "flag.fill")
    case .incorrectFlag:
        Image(systemName: "flag.slash")
    case .unrevealedMine:
        Image(systemName: "seal.fill")
    }
}

var fillColor: Color {
    switch self {
    case .flagged, .unrevealed, .unrevealedMine, .incorrectFlag, .flaggedMine:
        return Theme.unrevealedFill
    case .revealed:
        return Theme.revealedFill
    case .revealedMine:
        return Theme.failure
    }
}

var foregroundColor: Color {
    switch self {
    case .revealedMine, .unrevealedMine, .unrevealed, .flagged:
        return Theme.primary
    case .revealed(let count):
        return Theme.foreground(for: count)
    case .incorrectFlag:
        return Theme.failure
    case .flaggedMine:
        return Theme.success
    }
}

So with all of this, the base game is working, so next is going to be adding right click, and possibly some other small playability enhancements.