iOS приложение игры 2048 в SwiftUI с ChatGPT. Часть 3. ИИ (AI) для игры 2048.

В двух предыдущих постах мы рассмотрели создание логики игры 2048 и разработку UI с анимацией. В этом посте мы добавим ИИ (искусственный интеллект ) для игры 2048 в виде алгоритмов Expectimax и Monte Carlo. Код находится на Github.

ШАГ 16.  Добавление AI в игру 2048

Добавление ИИ в игру 2048 подразумевает реализацию логики, которая может автоматически выбирать лучший ход на каждом шаге. ИИ будет, например, использовать функцию bestMoveDirection(), которую мы ранее обсуждали, чтобы определить, какой ход выполнить, основываясь на максимальном увеличении счета. В этом случае ИИ может автоматически играть в игру 2048, делая оптимальные ходы.

Таким образом, нам понадобится метод выполнения хода ИИ, возможность запуска его автоматически с определенной периодичностью, и, переключатель для переключения между ручным  режимом со swipe жестом и воспроизведением ИИ.

Но давайте сначала поймем, какие в SwiftUI есть средства запуска определенного кода автоматически через равные промежутки времени:

import SwiftUI

struct PeriodicTaskView: View {
    @State private var counter = 0
    
    // Create a timer publisher that fires every second
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        VStack {
            Text("Counter: \(counter)")
                .font(.largeTitle)
                .padding()

            // Example of something happening periodically
            Text("This text will update every second.")
        }
        .onReceive(timer) { _ in
            // Increment the counter every time the timer fires
            counter += 1
            
            // Place any other periodic code here
            print("Timer fired. Counter is now \(counter).")
        }
    }
}

 #Preview {
            PeriodicTaskView()
 }
struct GameView: View {
    @ObservedObject var viewModel: GameViewModel
    @State private var isAIEnabled = false
    
    // Create a timer publisher that fires every second
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    var body: some View {
        VStack {
            Toggle("Enable AI", isOn: $isAIEnabled)
                .padding()

            // Your game UI components go here...

        }
        .onReceive(timer) { _ in
            if isAIEnabled {
                let direction = viewModel.bestMoveDirection()
                viewModel.move(direction)
            }
        }
    }
}

Использование модификатора .onReceive (timer) и Timer.publish в GameView

import SwiftUI

struct GameView: View {
    @ObservedObject var viewModel = GameViewModel ()

    let tileSize: CGFloat = 80
    let padding: CGFloat = 8
    
    @State var isAIPlaying = false
    @State private var isShowingOptimalDirection = false
    
    // Timer that triggers every 0.5 seconds
    private let timer = 
               Timer.publish(every: 0.5, on: .main, in:.common).autoconnect()
    
    var body: some View {
        VStack {
            // Your game UI components here (e.g., grid view, score display)...

            HStack {
                Button(action: {
                    isAIPlaying.toggle()
                }) {
                    HStack {
                        Image(systemName: 
                                 isAIPlaying ? "checkmark.square" : "square")
                            .resizable()
                            .frame(width: 24, height: 24)
                                                   
                       Text( isAIPlaying ? "AI Stop" : "AI Play")
                     }
                }
                .padding()
                .background(.accentColor)
            }
            
            if viewModel.isGameOver {
                Text(viewModel.isGameOver  ? "Game Over": " ___ ")
                    .font(.title)
                    .foregroundColor(viewModel.isGameOver  ? .red : .clear)
            }
        }
        .padding()

        // This triggers AI moves at intervals when AI is playing
        .onReceive(timer) { _ in
            if isAIPlaying {
                viewModel.executeAIMove()
            }
        }
    }
}
class GameViewModel: ObservableObject {
    @Published var tiles: [[Tile]] = []
    @Published var score: Int = 0
        
    private var aiGame = AIGame()
    
    init() {
        resetGame()
    }
    
    func resetGame() { . . .}
        // Reset the game board, score, and other states    
     
     func executeAIMove() {
        var  bestDirection : Direction 
        guard !isGameOver else { return }
       
        bestDirection = bestMoveDirection()
        move(bestDirection)
        }
           
    func bestMoveDirection() -> Direction {
        var bestDirection: Direction = .right
        var maxScore = 0
        
        for direction in Direction.allCases {
            let result = 
                      aiGame.oneStepGame(direction: direction, matrix: tiles)
            if result.moved && result.score >= maxScore {
                maxScore = result.score
                bestDirection = direction
            }
        }
        
        return bestDirection
    }
    
    func move(_ direction: Direction) {
        // Logic to slide and merge tiles, add newTile if moved and gain the score
        let (moved, score) = slide(direction)
        
        if moved {
            self.score += score
            addNewTile()
        }
        checkGameOver()
    }

    private func checkGameOver() {
        if !canMove() {
            isGameOver = true
        }
    }
    
    private func canMove() -> Bool {
        return Direction.allCases.contains { direction in
            aiGame.oneStepGame(direction: direction, matrix: tiles).moved
        }
    }
    
    private func addNewTile() {
        // Logic to add a new tile at a random empty position
    }
    
    func slide(_ direction: Direction) -> (moved: Bool, score: Int) {
        // Logic to slide and merge tiles, returning whether any tiles moved and the score gained
        var moved = false
        var totalScore = 0
        
        // Rotate board, compress, merge, and update rows...
        
        return (moved, totalScore)
    }
}

Checkmark кнопка isAIPlaying для 2048

import SwiftUI
struct GameView: View {
    @ObservedObject var viewModel: GameViewModel

    let tileSize: CGFloat = 80
    let padding: CGFloat = 8

    @State private var isAIPlaying = false
     @State private var isShowingOptimalDirection = false

// Create a timer publisher that fires every second 
    let timer = 
         Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack {
            // Your other game UI components here...

            HStack {
                Button(action: {
                   isAIEnabled.toggle()
                }) {
                    HStack {
                        Image(systemName: isAIPlaying ? 
                                               "checkmark.square" : "square")
                            .resizable()
                            .frame(width: 24, height: 24)
                                                   
                         Text(isAIPlaying ? "AI Stop" : "AI Play")
                            .foregroundColor(.black)
                    }
                }
                .padding()
        }
            
            // Display other game-related UI components here...
            // Game Over
            Text(viewModel.isGameOver  ? "Game Over": " ___ ")
                    .font(.title)
                    .foregroundColor(viewModel.isGameOver  ? .red : .clear)
        }
         .onReceive(timer){ value in
            if isAIPlaying {
                viewModel.executeAIMove()
            }
       }
}

Вот как это выглядит в нашем случае:
Код GameView:

struct GameView: View {
    @ObservedObject private var viewModel = GameViewModel()

    let tileSize: CGFloat = 80
    let padding: CGFloat = 8
    
   @State private var isShowingOptimalDirection = false    
   @State var isAIPlaying = false

  let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
    var body: some View {
        VStack {
            Text("2048")
                .font(.largeTitle)
                .padding()
            
            HStack {
                // Score Display
                Text("Score: \(viewModel.score)")
                Spacer()
                // AI
                Button(action: {
                    isAIPlaying.toggle()
                }) {
                    HStack {
                      Image(systemName: isAIPlaying ? 
                                             "checkmark.square" : "square")
                            .resizable()
                            .frame(width: 34, height: 34)
                       Text(isAIPlaying ? "AI Play" : "AI Stop")
                    }
                }
            }
            .font(.title)
            .foregroundColor(.accentColor)
            .padding()
                 
              // Display other game-related UI components here...
        }
      .onReceive(timer){ value in
            if isAIPlaying {
                viewModel.executeAIMove()
            }
        }
    }
    
    // Handle swipe gesture and trigger game actions
    private func handleSwipe(value: DragGesture.Value) {. . .}
    }
}

A вот наш UI:

ШАГ 17. Лучшая ИИ (AI) стратегия

func expectimax (board: [[Tile]], depth: Int, isAITurn: Bool) -> Double {
    // Base case: return the board evaluation if depth is 0 or game is over
    if depth == 0 || isGameOver(board) {
        return evaluateBoard(board)
    }

    // AI's move (maximize the score)
    if isAITurn {
        var maxScore = -Double.infinity
        for direction in Direction.allCases {
            let newBoard = makeMove(board, direction)
            if board != newBoard {
        // Recur for the next move, but now it's the tile placement's turn
                maxScore = 
              max(maxScore, expectimax(newBoard, depth - 1, isAITurn: false))
            }
        }
        return maxScore
    } 
    // Random tile placement's move (chance node)
    else {
        var expectedScore = 0.0
        let emptyTiles = findEmptyTiles(board)

        // If no empty tiles, the game is over
        if emptyTiles.isEmpty {
            return evaluateBoard(board)
        }

        // For each empty tile, calculate the expected value
        for tile in emptyTiles {
            let boardWith2 = addTile(board, tile, value: 2)
            let boardWith4 = addTile(board, tile, value: 4)

    // 90% probability of placing a '2' tile, 10% of placing a '4' tile
      expectedScore += 0.9 * expectimax(boardWith2, depth - 1, isAITurn: true)
      expectedScore += 0.1 * expectimax(boardWith4, depth - 1, isAITurn: true)
        }
        return expectedScore / Double(emptyTiles.count)
    }
}


func evaluate(_ tiles: [[Tile]]) -> Double {
    // A heuristic function to evaluate the current board state
    // e.g., sum of all tile values, number of empty spaces, etc.
}

ШАГ. 18 Как оптимизировать ИИ?

func evaluateBoard(_ board: [[Tile]]) -> Double {
    let monotonicityWeight = 1.0
    let smoothnessWeight = 1.0
    let emptyTilesWeight = 2.0
    let maxTileWeight = 0.5

    return monotonicity(board) * monotonicityWeight +
           smoothness(board) * smoothnessWeight +
           countEmptyTiles(board) * emptyTilesWeight +
           getMaxTile(board) * maxTileWeight
}
func expectimax(board: [[Tile]], depth: Int, isAITurn: Bool) -> Double {
    // Base case: return the board evaluation if depth is 0 or game is over
    if depth == 0 || isGameOver(board) {
        return evaluate(board)
    }

    // AI's move (maximize the score)
    if isAITurn {
        var maxScore = -Double.infinity
        for direction in Direction.allCases {
            let newBoard = makeMove(board, direction)
            if board != newBoard {
           // Recur for the next move, but now it's the tile placement's turn
                maxScore = 
            max(maxScore, expectimax(newBoard, depth - 1, isAITurn: false))
            }
        }
        return maxScore
    } 
    // Random tile placement's move (chance node)
    else {
        var expectedScore = 0.0
        let emptyTiles = findEmptyTiles(board)

        // If no empty tiles, the game is over
        if emptyTiles.isEmpty {
            return evaluateBoard(board)
        }

        // For each empty tile, calculate the expected value
        for tile in emptyTiles {
            let boardWith2 = addTile(board, tile, value: 2)
            let boardWith4 = addTile(board, tile, value: 4)

         // 90% probability of placing a '2' tile, 10% of placing a '4' tile
            expectedScore += 
                   0.9 * expectimax(boardWith2, depth - 1, isAITurn: true)
            expectedScore += 
                   0.1 * expectimax(boardWith4, depth - 1, isAITurn: true)
        }
        return expectedScore / Double(emptyTiles.count)
    }
}

ШАГ 18. Алгоритм Expectimax

enum Direction: CaseIterable {
    case up, down, left, right
}
struct Tile : Equatable, Identifiable {
    var value: Int
    var position: Position
    var id = UUID()  // This provides a unique identifier for each tile
    
    // Manually implement Equatable conformance
    static func == (lhs: Tile, rhs: Tile) -> Bool {
        return lhs.value == rhs.value
    }
}

struct Position: Equatable {
    var row: Int
    var col: Int
}
func expectimax(board: [[Tile]], depth: Int, isAITurn: Bool) -> Double {
      // Base case: return the board evaluation if depth is 0 or game is over
        if depth == 0 || isGameOver(board) {
            return evaluateBoard  (board)
        }
        
        // AI's move (maximize the score)
        if isAITurn {
            var maxScore = -Double.infinity
            for direction in Direction.allCases {
                let newBoard = GameViewModel (matrix: board)
                let (moved, _) = newBoard.slide(direction)
                if moved {
                 // Recur for the next move, but now it's the tile placement's turn
                    maxScore = max(maxScore, 
        expectimax(board: newBoard.tiles, depth: depth - 1, isAITurn: false))
                }
            }
            return maxScore
        }
        // Random tile placement's move (chance node)
        else {
            var expectedScore = 0.0
            let emptyTiles = board.flatMap{$0}.filter{$0.value == 0}
            // If no empty tiles, the game is over
            if emptyTiles.isEmpty {
                return evaluateBoard (board)
            }
            
            // For each empty tile, calculate the expected value
            for tile in emptyTiles {
                var boardWith2 = board
                boardWith2[tile.position.row][tile.position.col].value = 2
                var boardWith4 = board
                boardWith4[tile.position.row][tile.position.col].value = 4
                
                // 90% probability of placing a '2' tile, 10% of placing a '4' tile
                expectedScore += 
        0.9 * expectimax(board: boardWith2, depth: depth - 1, isAITurn: true)
                expectedScore += 
        0.1 * expectimax(board: boardWith4, depth: depth - 1, isAITurn: true)
            }
            return expectedScore / Double(emptyTiles.count)
        }
    }

  func evaluateBoard(_ board: [[Tile]]) -> Double {
        let monotonicityWeight = 1.0
        let smoothnessWeight = 0.1
        let emptyTilesWeight = 2.7
        let maxTileWeight = 1.0

        let emptyTilesCount = 
               Double(board.flatMap{$0}.filter{$0.value == 0}.count)
              
        return monotonicity(board) * monotonicityWeight +
               smoothness(board) * smoothnessWeight +
               emptyTilesCount * emptyTilesWeight +
               maxTileInCorne() * maxTileWeight
    }
    
    func  monotonicity (_ board: [[Tile]]) -> Double {
        // calculate
        return 0.0
    }
    func  smoothness (_ board: [[Tile]]) -> Double {
        // calculate
        return 0.0
    }

    func maxTileInCorner(_ board: [[Tile]]) -> Double 
        // calculate
        return 0.0
    }
// MARK: - Expectimax
    func expectimaxBestMove (depth: Int, matrix: [[Tile]]) -> Direction {
        var bestDirection = Direction.right
        var bestScore: Double = -Double.infinity
 
       // for move in possibleMoves {
        for direction in Direction.allCases {
            var model = GameViewModel (matrix: matrix) // Initialize Game
            let (moved, _ ) = model.slide(direction)
            if moved {
               let newScore = 
          expectimaxScore (board: model.tiles, depth: depth, isAITurn: false)
               if newScore > bestScore {
                    bestScore = newScore
                    bestDirection = direction
                }
            }
        }
        return bestDirection
    }
class GameViewModel: ObservableObject {
    @Published var tiles: [[Tile]] = []
    @Published var isGameOver = false
    @Published var score: Int = 0
        
    private var aiGame = AIGame()
    
    init() {
        resetGame()
    }
    
    func resetGame() { . . .}
        // Reset the game board, score, and other states    
     
    // ------ AI ---------
    func executeAIMove() {
            guard !isGameOver else { return }
            move(bestAIMoveDirection())
    }
      func bestAIMoveDirection() -> Direction {
           aiGame.expectimaxBestMove(depth: 4, matrix: tiles)
      }
              
     // Other functions: move, slide, compress, merge, and update rows...
}
import SwiftUI

struct GameView: View {
    @ObservedObject var viewModel = GameViewModel ()

    let tileSize: CGFloat = 80
    let padding: CGFloat = 8
    
    @State var isAIPlaying = false
    @State private var isShowingOptimalDirection = false
    
    // Timer that triggers every 0.5 seconds
    private let timer = 
            Timer.publish(every: 0.5, on: .main, in:.common).autoconnect()
    
    var body: some View {
        VStack {
            // Your game UI components here (score display)...

            HStack {
                Button(action: {
                    isAIPlaying.toggle()
                }) {
                    HStack {
                     Image(systemName: 
                                 isAIPlaying ? "checkmark.square" : "square")
                            .resizable()
                            .frame(width: 24, height: 24)
                                                   
                      Text(isAIPlaying ? "AI Stop" : "AI Play")
                     }
                }
                .padding()
            }
            
            if viewModel.isGameOver {
                Text(viewModel.isGameOver  ? "Game Over": " ___ ")
                    .font(.title)
                    .foregroundColor(viewModel.isGameOver  ? .red : .clear)
            }
       // Your game UI components here (e.g., grid view, reset display)...
        }
        .padding()

        // This triggers AI moves at intervals when AI is playing
        .onReceive(timer) { _ in
            if isAIPlaying {
                viewModel.executeAIMove()
            }
        }
    }
}

Вот как работает expectimax поиск оптимального хода:

ШАГ 19. Улучшение функции evaluate()

func monotonicity (_ grid: [[Int]]) -> Double {
        func calculateMonotonicity(values: [Int]) -> (Double, Double) {
            var increasing = 0.0
            var decreasing = 0.0
            var current = 0
            // Skip over any initial zeros in the row/column
            while current < values.count && values[current] == 0 {
                current += 1
            }
            var next = current + 1
            while next < values.count {
                // Skip over any zeros in the middle
                while next < values.count && values[next] == 0 {
                    next += 1
                }
                if next < values.count {
                    let currentValue = values[current] != 0 ?    
                                          log2(Double(values[current])) : 0
                    let nextValue = values[next] != 0 ? 
                                          log2(Double(values[next])) : 0
                    if currentValue > nextValue {
                        decreasing += nextValue - currentValue
                    } else if currentValue < nextValue {
                        increasing += currentValue - nextValue
                    }
                    // Move to the next non-zero tile
                    current = next
                    next += 1
                }
            }
            return (increasing, decreasing)
        }
        var rowMonotonicity = (increasing: 0.0, decreasing: 0.0)
        var colMonotonicity = (increasing: 0.0, decreasing: 0.0)
        // Check row monotonicity (left-right)
        for row in grid {
            let (increasing, decreasing) = calculateMonotonicity(values: row)
            rowMonotonicity.increasing += increasing
            rowMonotonicity.decreasing += decreasing
         }
        // Check column monotonicity (up-down)
        for col in 0..<grid[0].count {
            let columnValues = grid.map { $0[col] }
            let (increasing, decreasing) = 
                                  calculateMonotonicity(values: columnValues)
            colMonotonicity.increasing += increasing
            colMonotonicity.decreasing += decreasing
        }
        return max(rowMonotonicity.increasing, rowMonotonicity.decreasing) +
               max(colMonotonicity.increasing, colMonotonicity.decreasing)
    }
func smoothness(_ grid: [[Int]]) -> Double {
      var smoothness: Double = 0
      for row in 0..<4 {
          for col in 0..<4 {
              if grid[row][col] != 0 {
                 let value = Double(grid[row][col])
                 if col < 3 && grid[row][col+1] != 0 {
                     smoothness -= abs(value - Double(grid[row][col+1]))
                 }
                 if row < 3 && grid[row+1][col] != 0 {
                      smoothness -= abs(value - Double(grid[row+1][col]))
                 }
              }
          }
      }
       return smoothness
  }
func emptyTileCount(_ board: [[Tile]]) -> Int {
    return board.flatMap { $0 }.filter { $0.value == 0 }.count
}
func maxTileInCorner(_ board: [[Tile]]) -> Double {
    let maxTile = board.flatMap { $0 }.max(by: { $0.value < $1.value })?.value ?? 0
    let cornerTiles = [
        board[0][0], board[0][3],
        board[3][0], board[3][3]
    ]
    return cornerTiles.contains(where: { $0.value == maxTile }) ? 1.0 : 0.0
}

Объединение эвристик в функцию оценки игровой доски evaluate()

func evaluateBoard(_ board: [[Tile]]) -> Double {
    let emptyWeight = 2.7
    let smoothnessWeight = 0.1
    let monotonicityWeight = 1.0
    let maxTileCornerWeight = 1.0

    let emptyTilesScore = Double(emptyTileCount(board)) * emptyWeight
    let smoothnessScore = smoothness(board) * smoothnessWeight
    let monotonicityScore = monotonicity(board) * monotonicityWeight
    let maxTileInCornerScore = maxTileInCorner(board) * maxTileCornerWeight
    
    return emptyTilesScore + smoothnessScore + monotonicityScore + maxTileInCornerScore
}

ШАГ 20. Гладкость smoothness

func smoothness(board: [[Tile]]) -> Double {
    var smoothnessScore = 0.0

    // Iterate through each tile on the board
    for row in 0..<board.count {
        for col in 0..<board[row].count {
            let currentTile = board[row][col]

            // Skip empty tiles
            if currentTile.value == 0 {
                continue
            }

            // Compare with the tile to the right (horizontal neighbor)
            if col + 1 < board[row].count {
                let rightTile = board[row][col + 1]
                if rightTile.value != 0 {
                    smoothnessScore -= abs(log2(Double(currentTile.value)) -           
                                           log2(Double(rightTile.value)))
                }
            }

            // Compare with the tile below (vertical neighbor)
            if row + 1 < board.count {
                let belowTile = board[row + 1][col]
                if belowTile.value != 0 {
                    smoothnessScore -= abs(log2(Double(currentTile.value)) -   
                                           log2(Double(belowTile.value)))
                }
            }
        }
    }

    return smoothnessScore
}
let smoothnessScore = smoothness(board)

ШАГ 21. Монотонность monotonicity

func monotonicity (_ grid: [[Int]]) -> Double {
        func calculateMonotonicity(values: [Int]) -> (Double, Double) {
            var increasing = 0.0
            var decreasing = 0.0
            var current = 0
            // Skip over any initial zeros in the row/column
            while current < values.count && values[current] == 0 {
                current += 1
            }
            var next = current + 1
            while next < values.count {
                // Skip over any zeros in the middle
                while next < values.count && values[next] == 0 {
                    next += 1
                }
                if next < values.count {
                    let currentValue = values[current] != 0 ?            
                                           log2(Double(values[current])) : 0
                    let nextValue = values[next] != 0 ? 
                                           log2(Double(values[next])) : 0
                    if currentValue > nextValue {
                        decreasing += nextValue - currentValue
                    } else if currentValue < nextValue {
                        increasing += currentValue - nextValue
                    }
                    // Move to the next non-zero tile
                    current = next
                    next += 1
                }
            }
            return (increasing, decreasing)
        }
        var rowMonotonicity = (increasing: 0.0, decreasing: 0.0)
        var colMonotonicity = (increasing: 0.0, decreasing: 0.0)
        // Check row monotonicity (left-right)
        for row in grid {
            let (increasing, decreasing) = calculateMonotonicity(values: row)
            rowMonotonicity.increasing += increasing
            rowMonotonicity.decreasing += decreasing
          //  print (rowMonotonicity)
        }
        // Check column monotonicity (up-down)
        for col in 0..<grid[0].count {
            let columnValues = grid.map { $0[col] }
            let (increasing, decreasing) = 
                               calculateMonotonicity(values: columnValues)
            colMonotonicity.increasing += increasing
            colMonotonicity.decreasing += decreasing
         //   print (colMonotonicity)
        }
        return max(rowMonotonicity.increasing, rowMonotonicity.decreasing) +
               max(colMonotonicity.increasing, colMonotonicity.decreasing)
    }
let monotonicityScore = monotonicity(board)

Версия функции monotonicity1 с использованием функций высшего порядка

func monotonicity1(_ board: [[Int]]) -> Double {
        // The same as monotonicity2
        
        let grid:[[Int]] = board.map{$0.map{ $0 != 0 ? 
                                                  Int(log2(Double($0))): 0}}
        func monotonicityScore(_ arr: [Int]) -> (Double, Double) {
            let arrNonZero = arr.filter { $0 != 0 }
    
            let increasingScore = zip(arrNonZero, arrNonZero.dropFirst())
                    .filter {$0 >= $1}.map {Double($1 - $0) }.reduce(0.0, +)
            let decreasingScore = zip(arrNonZero, arrNonZero.dropFirst())
                    .filter {$0 <= $1}.map {Double($0 - $1) }.reduce(0.0, +)
            return (increasingScore, decreasingScore)
        }
        
        let rowScores = grid.map(monotonicityScore)
        let rowIncreasing = rowScores.map {$0.0}
        let rowDecreasing = rowScores.map {$0.1}
        
        let columns = (0..<grid[0].count).map { col in grid.map { $0[col] } }
        let columnScores = columns.map(monotonicityScore)
        let columnIncreasing = columnScores.map {$0.0}
        let columnDecreasing = columnScores.map {$0.1}
        
        let totalScore = max (rowIncreasing.reduce(0.0, +), 
                              rowDecreasing.reduce(0.0, +)) + 
                         max (columnIncreasing.reduce(0.0, +),
                              columnDecreasing.reduce(0.0, +))
        return totalScore
    }

// Test case for strictly increasing row monotonicity
        func testIncreasingRowMonotonicity() {
            let grid = [
                [2, 4, 8, 16],
                [8, 16, 0, 32],
                [32, 16, 64, 8],
                [8, 16, 32, 64]
            ]
            let result = aiGame.monotonicity(grid)
            XCTAssertEqual(result, -9.0, 
                      "Monotonicity score for increasing row is incorrect.")
            let result2 = aiGame.monotonicity2(grid)
            XCTAssertEqual(result2, -9.0, 
                       "Monotonicity score for increasing row is incorrect.")
            let result1 = aiGame.monotonicity1(grid)
            XCTAssertEqual(result1, -9.0, 
                       "Monotonicity score for increasing row is incorrect.")
        }


// Test case for monotonicity in rows
        func testColumnMonotonicity1() {
            let grid = [
                [2, 4, 8, 16],
                [4, 8, 16, 32],
                [2, 0, 2, 0],
                [0, 4, 8, 16]
            ]
            let result = aiGame.monotonicity(grid)
            XCTAssertEqual(result, -7.0, 
                   "Monotonicity score for increasing column is incorrect.")
            let result2 = aiGame.monotonicity2(grid)
            XCTAssertEqual(result2, -6.0, 
                      "Monotonicity score for increasing row is incorrect.")
            let result1 = aiGame.monotonicity1(grid)
            XCTAssertEqual(result1, -6.0, 
                      "Monotonicity score for increasing row is incorrect.")
        }
    
    // Test case for strictly decreasing row monotonicity
        func testDecreasingRowMonotonicity() {
            let grid = [
                [16, 8, 4, 2],
                [4, 0, 2, 0],
                [8, 4, 0, 0],
                [32, 4, 8, 16]
            ]
            let result = aiGame.monotonicity(grid)
            XCTAssertEqual(result, -6, 
                      "Monotonicity score for decreasing row is incorrect.")
            let result2 = aiGame.monotonicity2(grid)
            XCTAssertEqual(result2, -6, 
                      "Monotonicity score for increasing row is incorrect.")
            let result1 = aiGame.monotonicity1(grid)
            XCTAssertEqual(result1, -6, 
                      "Monotonicity score for increasing row is incorrect.")
        }
    
    // Test case for mixed values row
        func testMixedRowMonotonicity() {
            let grid = [
                [2, 16, 4, 8],
                [0, 0, 0, 0],
                [0, 0, 0, 0],
                [0, 0, 0, 0]
            ]
            let result = aiGame.monotonicity(grid)
            XCTAssertEqual(result, -2.0, 
                            "Monotonicity score for mixed row is incorrect.")
            let result2 = aiGame.monotonicity2(grid)
            XCTAssertEqual(result2, -2.0, 
                      "Monotonicity score for increasing row is incorrect.")
            let result1 = aiGame.monotonicity1(grid)
            XCTAssertEqual(result1, -2.0, 
                      "Monotonicity score for increasing row is incorrect.")
        }
    
    // Test case for monotonicity in columns
        func testColumnMonotonicity() {
            let grid = [
                [2, 8, 0, 0],
                [4, 16, 0, 0],
                [0, 0, 0, 0],
                [16, 32, 0, 0]
            ]
            let result = aiGame.monotonicity(grid)
            XCTAssertEqual(result, -5.0, 
                   "Monotonicity score for increasing column is incorrect.")
            let result2 = aiGame.monotonicity2(grid)
            XCTAssertEqual(result2, 0.0, 
                      "Monotonicity score for increasing row is incorrect.")
            let result1 = aiGame.monotonicity1(grid)
            XCTAssertEqual(result1, 0.0, 
                      "Monotonicity score for increasing row is incorrect.")
        }
    
    // Test case for penalty when zeros are present
       func testZerosPenaltyMonotonicity() {
           let grid = [
               [2, 0, 8, 16],
               [4, 0, 0, 0],
               [0, 0, 0, 4],
               [0, 0, 0, 0]
           ]
           let result = aiGame.monotonicity(grid)
           XCTAssertEqual(result, -3.0, 
                             "Monotonicity score should penalize zeros.")
           let result2 = aiGame.monotonicity2(grid)
           XCTAssertEqual(result2, -1.0, 
                       "Monotonicity score for increasing row is incorrect.")
           let result1 = aiGame.monotonicity1(grid)
           XCTAssertEqual(result1, -1.0, 
                       "Monotonicity score for increasing row is incorrect.")
       }
    
    // Test case for empty grid
        func testEmptyGridMonotonicity() {
            let grid = [
                [0, 0, 0, 0],
                [0, 0, 0, 0],
                [0, 0, 0, 0],
                [0, 0, 0, 0]
            ]
            let result = aiGame.monotonicity(grid)
            XCTAssertEqual(result, 0.0, 
                          "Monotonicity score for empty grid is incorrect.")
            let result2 = aiGame.monotonicity2(grid)
            XCTAssertEqual(result2, 0.0, 
                      "Monotonicity score for increasing row is incorrect.")
            let result1 = aiGame.monotonicity1(grid)
            XCTAssertEqual(result1, 0.0, 
                      "Monotonicity score for increasing row is incorrect.")
        }

ШАГ 21 Разница между монотонностью Monotonicity и “гладкостью” Smoothness. 

[64, 32, 16, 8]
[32, 16, 8, 4]
[16, 8, 4, 2]
[8, 4, 2, 0]
[16, 16, 8, 8]
[8, 8, 4, 4]
[4, 4, 2, 2]
[2, 2, 0, 0]

ШАГ 22. Эвристика в виде Snake (Змея) паттерна

Два способа организации игровой доски в виде Snake паттерна показаны на рисунке:

Матрица весов для Snake паттерна игры 2048

[15, 14, 13, 12]
[8,  9,  10, 11]
[7,  6,  5,  4]
[0,  1,  2,  3]
func snakeHeuristic(_ board: [[Tile]]) -> Double {
    // Snake pattern score weights for each tile position
    let snakePattern: [[Double]] = [
        [15, 14, 13, 12],
        [8,  9,  10, 11],
        [7,  6,  5,  4],
        [0,  1,  2,  3]
    ]
    
    var score = 0.0

    // Evaluate how well the board follows the snake pattern
    for row in 0..<4 {
        for col in 0..<4 {
            let tileValue = board[row][col].value
            if tileValue > 0 {
            score += Double(log2(Double(tileValue))) * snakePattern[row][col]
            }
        }
    }

    return score
}
func evaluateBoard(_ board: [[Tile]]) -> Double {
        let monotonicityWeight = 1.0
        let smoothnessWeight = 0.1
        let emptyTilesWeight = 5.7
        let maxTileWeight = 0.5

        let emptyTilesCount = 
               Double(board.flatMap{$0}.filter{$0.value == 0}.count)
              
        return monotonicity(board) * monotonicityWeight +
               smoothness(board) * smoothnessWeight +
               emptyTilesCount * emptyTilesWeight +
               maxTileInCorne() * maxTileWeight +
               snakeHeuristic (board)
    }
[2^15, 2^14, 2^13, 2^12]
[2^8,  2^9,  2^10, 2^11]
[2^7,  2^6,  2^5,  2^4]
[2^0,  2^1,  2^2,  2^3]
let snakePattern: [[Double]] = [
    [pow(2, 15), pow(2, 14), pow(2, 13), pow(2, 12)],
    [pow(2, 8),  pow(2, 9),  pow(2, 10), pow(2, 11)],
    [pow(2, 7),  pow(2, 6),  pow(2, 5),  pow(2, 4)],
    [pow(2, 0),  pow(2, 1),  pow(2, 2),  pow(2, 3)]
]
func snakeHeuristic(_ board: [[Tile]]) -> Double {
    // Snake pattern score weights for each tile position based on powers of 2
    let snakePattern: [[Double]] = [
        [pow(2, 15), pow(2, 14), pow(2, 13), pow(2, 12)],
        [pow(2, 8),  pow(2, 9),  pow(2, 10), pow(2, 11)],
        [pow(2, 7),  pow(2, 6),  pow(2, 5),  pow(2, 4)],
        [pow(2, 0),  pow(2, 1),  pow(2, 2),  pow(2, 3)]
    ]
    
    var score = 0.0

    // Evaluate how well the board follows the snake pattern
    for row in 0..<4 {
        for col in 0..<4 {
            let tileValue = board[row][col].value
                score += Double(tileValue) * snakePattern[row][col]
        }
    }

    return score
}

Вот наша evaluate() функция:


func evaluateBoard (_ board: [[Tile]]) -> Double {
        let grid = board.map {$0.map{$0.value}}
        let emptyCells = board.flatMap { $0 }.filter { $0.value == 0 }.count
               let smoothWeight: Double = 0.1
            let monoWeight: Double = 1.0
            let emptyWeight: Double = 5.7
            let maxWeight: Double = 1.0
         //   let maxTileCornerWeight = 1.0
        
                return monoWeight *  monotonicity(grid)
                 + smoothWeight * smoothness(grid)
                 + emptyWeight * Double(emptyCells)
                 + maxWeight * Double(grid.flatMap { $0 }.max() ?? 0)
             //    + maxTileCornerWeight * maxTileInCorner(board)
                 + snakeHeuristic(grid)
     }

ШАГ 23. Метод Monte Carlo как ИИ для игры 2048

func monteCarloSearch(board: [[Tile]], simulations: Int, depth: Int) -> Direction {
        var bestDirection: Direction = .up
        var bestScore: Double = -Double.infinity
        
        // Iterate over all possible moves
        for direction in Direction.allCases {
            var totalScore: Double = 0
            
            // Simulate a number of games for each move
            for _ in 0..<simulations {
                var gameBoard = GameViewModel(matrix: board)
                let (moved, _) = gameBoard.slide(direction)
                if moved {
                    // Play a random game starting from this move
                  let score = randomGame(board: gameBoard.tiles, depth: depth)
                    totalScore += score
                }
            }
            
            // Calculate the average score for this move
            let averageScore = totalScore / Double(simulations)
            
            // Select the move with the highest average score
            if averageScore > bestScore {
                bestScore = averageScore
                bestDirection = direction
            }
        }
        
        return bestDirection
    }
func randomGame(board:[[Tile]], depth: Int) -> Double{
        var moves = 0
        var gameBoard = GameViewModel(matrix:board)

       // Play until no more moves or reach max depth
        while !isGameOver(gameBoard.tiles) && moves < depth {
           let randomMove = Direction.allCases.randomElement()!
            gameBoard.move (randomMove)
            moves += 1
       }
       
       // Evaluate the board at the end of the game
       return evaluateBoard(gameBoard.tiles)
    }
func evaluateBoard(_ board: [[Tile]]) -> Double {
    // Use a heuristic to evaluate the current state of the board
    // For example: Sum of tiles, number of empty spaces, smoothness, monotonicity, etc.
}

ШАГ 24. Усовершенствование Monte Carlo как ИИ для игры 2048

func biasedRandomGame(direction: Direction,board:[[Tile]], depth: Int) -> Double{
        var moves = 0
        var gameBoard = GameViewModel(matrix:board)
       
// Play until no more moves or reach max depth
        while !isGameOver(gameBoard.tiles) && moves < depth {
           let biasedMoves = biasedMoveSelection(board: gameBoard.tiles)
           let randomMove = biasedMoves.randomElement()!
            gameBoard.move (randomMove)
            moves += 1
       }
       
       // Evaluate the board at the end of the game
       return evaluateBoard(gameBoard.tiles)
    }

func biasedMoveSelection(board: [[Tile]]) -> [Direction] {
        var possibleMoves: [Direction] = []
        
        for direction in Direction.allCases {
    
            var gameBoard = GameViewModel(matrix:board)
            let (moved, _) = gameBoard.slide(direction)
            if moved {
     // Prioritize moves that make the board smoother or merge tiles
             if mergesTiles(gameBoard.tiles) || isBoardSmoother(gameBoard.tiles) {
                    possibleMoves.append(direction)
                } else {
                    possibleMoves.append(direction)
                }
            }
        }
        
        return possibleMoves.isEmpty ? Direction.allCases : possibleMoves
    }
 func randomGameWithEarlyStopping(board: [[Tile]], depth: Int, maxBadMoves: Int = 3) -> Double {
        var moves = 0
        var badMoves = 0
        var gameBoard = GameViewModel(matrix:board)

        // Play until no more moves or reach max depth
         while !isGameOver(gameBoard.tiles) && moves < depth {
            let randomMove = Direction.allCases.randomElement()!
            let (moved, _) = gameBoard.slide( randomMove)
            
            if moved {
                gameBoard.addNewTile()
            } else {
                badMoves += 1
                if badMoves >= maxBadMoves {
                    break
                }
            }
            moves += 1
        }
        
        return evaluateBoard(gameBoard.tiles)
    }
func monteCarloSearchWithDynamicSimulations(board: [[Tile]], maxSimulations: Int, depth: Int) -> Direction {
    var bestDirection: Direction = .up
    var bestScore: Double = -Double.infinity
    
    // Adjust simulations based on the number of empty tiles
    let emptyTilesCount = board.flatMap{$0}.filter{$0.value == 0}.count
    let simulations = max(1, maxSimulations - emptyTilesCount * 2)
    
    for direction in Direction.allCases {
        var totalScore: Double = 0
        
        for _ in 0..<simulations {
            let gameBoard = GameViewModel(matrix: board)
            let (moved, _ ) = gameBoard.slide( direction)
            
            if moved {
                let score = randomGame(board:gameBoard.tiles, depth: depth)
                totalScore += score
            }
        }
        
        let averageScore = totalScore / Double(simulations)
        if averageScore > bestScore {
            bestScore = averageScore
            bestDirection = direction
        }
    }
    
    return bestDirection
}
func runSimulationsParallel(board: [[Tile]], direction: Direction, simulations: Int, depth: Int) -> Double {
    let queue = DispatchQueue.global(qos: .userInitiated)
    let group = DispatchGroup()
    var totalScore = 0.0
    
    for _ in 0..<simulations {
        queue.async(group: group) {
           var gameBoard = GameViewModel(matrix: board)
           let (moved, _) = gameBoard.slide(direction)
            if moved {
              let score = randomGame(board: gameBoard.tiles, depth: depth)
                DispatchQueue.main.sync {
                    totalScore += score
                }
            }
        }
    }
    
    group.wait()
    return totalScore / Double(simulations)
}
func evaluateBoard(_ board: [[Tile]]) -> Double {
    let smoothness = calculateSmoothness(board)
    let monotonicity = calculateMonotonicity(board)
    let emptySpaces = Double(getEmptyTiles(board).count)
    
    return smoothness + monotonicity + emptySpaces * 2
}

ШАГ 25. Сделай рефакторинг runSimulationsParallel с async await

func runSimulationsParallel(board: [[Tile]], direction: Direction, simulations: Int, depth: Int) async -> Double {
    var totalScore = 0.0
    
    // Create an array of tasks for parallel execution
    await withTaskGroup(of: Double.self) { taskGroup in
        for _ in 0..<simulations {
            taskGroup.addTask {
               let gameBoard = Game(matrix: matrix)
                let (moved, _) = gameBoard.slide(direction)
                
                if moved {
                  let score = randomGame(board:gameBoard.tiles, depth: depth)
                    return score
                } else {
                    return 0.0 // If no move is made, return a score of 0
                }
            }
        }
        
        // Collect all the results from the tasks
        for await score in taskGroup {
            totalScore += score
        }
    }
    
    return totalScore / Double(simulations)
}

ШАГ 26. Параллелизм expectimax с async await

import Foundation

   // Asynchronous expectimax algorithm with improved parallelism
   func expectimaxAsyn(grid: [[Tile]], depth: Int, isAITurn: Bool) async -> Double {
        
        // Base case: return the board evaluation if depth is 0 or game is over
        if depth == 0 || isGameOver (grid.map {$0.map{$0.value}}){
          // return evaluateBoard(grid.map {$0.map{$0.value}})
            return evaluateBoard(grid)
        }
        if isAITurn {
            //------
            // Player's turn (maximize the score)
            var maxScore = -Double.infinity
            
            // Use task group for parallel evaluation of all directions
            return await withTaskGroup(of: Double.self) { group in
                for direction in Direction.allCases {
                    group.addTask {
                        var game = Game (matrix: grid) // Initialize Game
                        let (moved, _) = game.slide( direction)
                        if moved {
                            return 
    await expectimaxAsyn (grid: game.tiles, depth: depth - 1, isAITurn: false)
                        }
                        return -Double.infinity
                    }
                }
                
                for await result in group {
                    maxScore = max(maxScore, result)
                }
                return maxScore
            }
            //------
           
        } else {
            // AI's turn (chance node)
        //    var expectedScore = 0.0
            let emptyTiles = grid.flatMap { $0 }.filter { $0.value == 0 }
            // If no empty tiles, the game is over
            if emptyTiles.isEmpty {
             //  return evaluateBoard(grid.map {$0.map{$0.value}})
                return evaluateBoard(grid)
            }
            // Limit parallelism at deeper levels to avoid overwhelming system
            if depth > 4 {//3 {
                var expectedValue = 0.0
                for tile in emptyTiles {
                    var boardWith2 = grid
                    boardWith2[tile.position.row][tile.position.col].value = 2
                    let valueFor2 = 
      await expectimaxAsyn(grid: boardWith2, depth: depth - 1, isAITurn: true)
                    
                    var boardWith4 = grid
                    boardWith4[tile.position.row][tile.position.col].value = 4
                    let valueFor4 = 
      await expectimaxAsyn(grid: boardWith4, depth: depth - 1, isAITurn: true)
                    expectedValue += 0.9 * valueFor2 + 0.1 * valueFor4
                }
                return expectedValue / Double(emptyTiles.count)
            } else {
                // Use task group for parallel execution in shallower levels
                return await withTaskGroup(of: Double.self) { group in
                    var expectedValue = 0.0
                    for tile in emptyTiles {
                        group.addTask {
                            var boardWith2 = grid
                    boardWith2[tile.position.row][tile.position.col].value = 2
                            return 
await expectimaxAsyn(grid: boardWith2, depth: depth - 1, isAITurn: true) * 0.9
                        }
                        group.addTask {
                            var boardWith4 = grid                                         
                    boardWith4[tile.position.row][tile.position.col].value = 4
                            return 
await expectimaxAsyn(grid: boardWith4, depth: depth - 1, isAITurn: true) * 0.1
                        }
                    }
                    
                    for await result in group {
                        expectedValue += result
                    }
                    return expectedValue / Double(emptyTiles.count)
                }
            }
        }
    }
// MARK: -  ExpectimaxAsync AI
  func bestExpectimaxAsync (depth: Int, matrix: [[Tile]]) async -> Direction {
        var bestDirection = Direction.right
        var bestScore: Double = -Double.infinity
               
       // for move in possibleMoves {
        for direction in Direction.allCases {
            var model = Game (matrix: matrix) // Initialize Game
          //  let (moved, _ ) = model.slide(move)
            let (moved, _ ) = model.slide(direction)
            if moved {
                let newScore = 
    await expectimaxAsyn (grid: model.tiles, depth: depth ,  isAITurn: false)
                if newScore > bestScore {
                    bestScore = newScore
                   // bestMove = move
                    bestDirection = direction
                }
            }
        }
        return bestDirection
    }
  func bestMoveDirectionExpectimaxAsync() async -> Direction {
    let direction = await aiGame.bestExpectimaxAsync(depth: 5, matrix: tiles)
        return direction
  }
 func expectimaxAsyncAIMove() {
        Task{
            let bestDirection =  await game.bestMoveDirectionExpectimaxAsync()
            game.move(bestDirection)
         } 
 }
.onReceive(timer){ value in
          if isAIPlaying  && !viewModel.isGameOver {
              if selectedAlgorithm == Algorithm.MonteCarloAsync {
                  viewModel.monteCarloAsyncAIMove()
              } else if selectedAlgorithm == Algorithm.Expectimax1 {
                  viewModel.expectimaxAsyncAIMove()
              } else {
                    viewModel.executeAIMove()
              }
           }
   }

Заключение:

Благодаря ChatGPT разработка iOS приложений стала более осмысленной. Не нужно отвлекаться на очевидные вещи типа создание кнопки или меню на UI — а сфокусироваться на высокоуровневых концепциях. То есть на самом интересном и важном. Это рождает желание попробовать что-то более рискованное и, возможно, более эффективное, не прикладывая при этом никаких дополнительных усилий. Иными словами просыпается чувство азарта и от программирования с ChatGPT получаешь истинное удовольствие.

Что же понравилось больше всего?

  1. ChatGPT сразу предлагает полную архитектуру вашего приложения с “заглушками” для конкретных методов и вычисляемых переменных, но которую вы можете дальше успешно развивать, ссылаясь на эти заглушки без дополнительных разъяснений.
  2. ChatGPT предлагает очень содержательные идентификаторы для переменных var, констант let и названий функций func, что существенно облегчает чтение кода и избавляет вас от того, чтобы “ломать голову” над этим. И вы также можете ссылаться на них в последующем диалоге с ChatGPT.
  3. ChatGPT 4-o в совершенстве владеет функциями высшего порядка для работы с коллекциями (map, flatMapcompactMapfilterallSatisfy) в Swift и всюду предлагает их, иногда в самых неожиданных ситуациях и самым изобретательным образом, что приятно удивляет.
  4. Прекрасно владеет архитектурой MVVM (возможно, и другими, просто не пробовала), предлагая как незащищенную модель, когда ViewModel и Model в одном классе (с протоколом ObservableObject или новым макросом @Observable), так и классическую защищенную модель: Model отдельно от ViewModel и View. Легко переходит от одной к другой.
  5. Расшифровывает все ошибки и даёт дельные советы по их исправлению.
  6. В большинстве случаев запоминает и хранит наработанный в процессе взаимодействия код для поставленной задачи на протяжении почти всей сессии и позволяет ссылаться на различные его этапы.
  7. Хорошо рефакторит код.
  8. Генерирует Unit тесты с использованием XCTest.
  9. Проявляет фантастическую эрудицию в части ИИ алгоритмов для игр типа 2048.

И много чего еще ….

Все свои предложения кода ChatGPT сопровождает такими подробными объяснениями, которые не даст вам ни один курс обучения. Так что параллельно идет очень  интенсивное обучение языку программирования Swift  и фреймворку SwiftUI (мне это вроде как не требовалось и я проверяла их с точки зрения приемлемости для новичков, но это реально впечатляет и даже для меня открывало что-то новое!!!). 

Недостатки:

  • Хотя держит контекст решаемой задачи в процессе одной сессии, код полного приложения приходится собирать по кусочкам, это вам не Claude 3.5 Sonnet. Однако к настоящему моменту появился новый способ взаимодействия — ChatGPT 4 Canvas, который полностью держит разрабатываемый проект, но я еще не пробовала.
  • Иногда «увиливает» от прямо поставленного вопроса.
  • Редко, но совершает непростительные ошибки в коде, которые приходится проверять.

При работе над iOS приложением игры 2048 с помощью chatGPT мне ни разу не пришлось обращаться к Google или StackOverFlow, так что ChatGPT вполне может заменить эти два инструмента при разработке iOS приложений.