Я хочу поделиться с вами опытом создания "с нуля" iOS приложения известной игры 2048 с элементами ИИ (искусственного интеллекта) в SwiftUI с помощью ChatGPT .

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

Мне хотелось написать игру 2048 именно на SwiftUI, пользуясь его прекрасной и мощной анимацией и приличным быстродействием , a также  предоставить в распоряжения пользователя не только “ручной” способ игры, когда Вы руководите тем, каким должен быть следующий ход: вверх, вниз, влево и вправо, но и ряд алгоритмов с оптимальной стратегией (метода Монте-Карлостратегий поиска по деревьям (Minimax, Expectimax) ), позволяющих АВТОМАТИЧЕСКИ выполнять ходы - вверх, вниз, влево и вправо - и добиться  плитки с числом 2048 и более (эти алгоритмы и называют алгоритмами “искусственного интеллекта” (ИИ)).  Необходимым элементом ИИ является алгоритм поиска, который позволяет смотреть вперед на возможные будущие позиции, прежде чем решить, какой ход он хочет сделать в текущей позиции.

2048 - это очень известная игра, и мне не нужно было объяснять ChatGPT ее правила, он сам всё про неё знает. Кроме того, оказалось, что ChatGPT прекрасно осведомлен об ИИ алгоритмах для игры 2048, так что мне вообще не пришлось описывать ChatGPT контекст решаемой задачи. И он предлагал мне множество таких неординарных решений, которые мне пришлось бы долго выискивать в научных журналах.

Чтобы вы в дальнейшем смогли оценить эти решения, я кратко напомню правила игры 2048.

Сама игра проста. Вам дается игровое поле размером 4×4, где каждая плитка может содержать число внутри себя. 

Рис.1 Пример хода в 2048. После хода “сдвиг влево” (left) на левой доске. Доска слева станет той, что расположена на рис. справа.
Рис.1 Пример хода в 2048. После хода “сдвиг влево” (left) на левой доске. Доска слева станет той, что расположена на рис. справа.

Числа на игровом поле всегда будут степенью двойки. Изначально есть только две плитки с номерами 2 или 4. Вы можете менять игровое поле, нажимая на клавиши со стрелками - вверх, внизвправовлево - и все плитки будут двигаться в этом направлении, пока не будет остановлены либо другой плиткой, либо границей сетки. Если две плитки с одинаковыми числами столкнутся во время движения, они сольются в новую плитку с их суммой. Новая плитка не может повторно слиться с другой соседней плиткой во время этого перемещения. После перемещения новая плитка с числом 2 или 4 случайным образом появится на одной из пустых плиток, после чего игрок делает новый ход.

Цель игры состоит в том, чтобы достичь плитки с числом 2048, но её можно рассматривать более широко и достигать плитку с максимально возможным числом. На самом деле существует система подсчета очков, применяемая к каждому ходу. Счет игрока начинается с нуля и увеличивается всякий раз, когда две плитки объединяются, на значение нового числа объединенной плитки. Если нет пустой ячейки и больше нет допустимых ходов, то игра заканчивается. 

Итак, моя задача заключалась не только в том, чтобы создать движок игры 2048 на Swift, но и разработать UI c анимацией движения плиток с помощью SwiftUI, a также задействовать ИИ (алгоритмы Expectimax и Monte Carlo) в игре 2048. При этом я хотела максимально использовать возможности ChatGPT.

В статье подробно рассмотрены следующие этапы разработки такого iOS приложения игры 2048 с помощью ChatGPT:

  1. Логика игры без анимации.

  2. Разработка UI (анимация перемещения плиток и появления новых случайных плиток, отображение оптимального направления перемещения плиток на игровом поле).

  3. Добавление AI (алгоритмы Greedy, Expectimax и MonteCarlo) в игру 2048 c автоматическим запуском.

    На третьем этапе я получила от ChatGPT два алгоритма ИИ - Expectimax и Monte Carlo - и их варианты, которые позволяют получать очень приличные результаты - плитки со значениями  4096 и 8092.

    Алгоритм Expectimax в действии
    Алгоритм Expectimax в действии
Алгоритм Monte Carlo в действи
Алгоритм Monte Carlo в действи

Заметьте, какой разный рисунок игры у этих двух совершенно разных ИИ алгоритмов, стремящихся к одному и тому же результату.

Я использовала бесплатную версию ChatGPT 4-o.mini (не самую мощную). Все вопросы задавались на английском и ответы от ChatGPT 4-o.mini тоже были получены на английском, но впоследствие переведены самим же ChatGPT для вас на русский. 

В плане движка игры 2048 и UI с анимацией ChatGPT дал возможность очень быстро получить понятный лаконичный код с отличными функциональными возможностями, хотя без ошибок не обошлось. У меня было очень мало ошибок, но они были завуалированными, которые не так-то просто найти. Вот тогда и приходилось прибегать к использованию тестов, которые ChatGPT генерирует отлично.

В плане ИИ для игры 2048 ChatGPT не просто поразил меня своей эрудицией, он предоставил мне готовыми такие ИИ алгоритмы, как Expextimax и Monte Carlo со множеством оптимизирующих возможностей для получения более значимого результата. И это не просто советы, все это сопровождается соответствующим кодом. В частности, использование Swift многопоточности позволило сделать код более эффективным, особенно для больших и глубоких деревьев поиска в игре 2048.

Еще более ценно то, что этот материал можно эффективно использовать для обучения программированию на Swift и SwiftUI не хуже Стэнфордского курса.

Полный протокол взаимодействия с ChatGPT при создании iOS приложения игры 2048 представлен в 3-х постах на моем сайте:

А код находится на GitHub. В этой статье я представляю лишь отдельные фрагменты этого протокола, чтобы вы могли почувствовать, насколько полезным может быть ChatGPT как при разработки, так и при обучении.

Логика игры 2048

Итак, первый запрос к ChatGPT:

ШАГ 1. Начало

код GameViwModel
import SwiftUI

enum Direction {
    case up, down, left, right
}

class GameViewModel: ObservableObject {
    @Published var board: [[Int]]
    @Published var gameOver = false

    init() {
        board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
        addNewTile()
        addNewTile()
    }

    func addNewTile() {
        var emptyTiles: [(Int, Int)] = []
        for row in 0..<4 {
            for col in 0..<4 {
                if board[row][col] == 0 {
                    emptyTiles.append((row, col))
                }
            }
        }
        if let randomTile = emptyTiles.randomElement() {
            board[randomTile.0][randomTile.1] = Bool.random() ? 2 : 4
        }
    }
    
    func resetGame(_ direction: Direction) {
        // Reset the board to the initial state
        board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
        gameOver = false
        addNewTile()
        addNewTile()

    }

    func move(_ direction: Direction) {
        // Implement the movement logic here
        // This will include merging tiles and updating the board state
        // After moving tiles, call addNewTile() to add a new tile
    }

    func checkGameOver() {
        // Implement the logic to check if the game is over
        // This will involve checking if there are any valid moves left
    }
}

код GameView
struct GameView: View {
    @StateObject private var viewModel = GameViewModel()

    var body: some View {
        VStack {
            Text("2048")
                .font(.largeTitle)
                .padding()
            
            GridView(board: viewModel.board)
                .padding()
            
            Button(action: {
                // Implement restart functionality
                viewModel = GameViewModel()
            }) {
                Text("Restart")
                    .font(.title)
                    .padding()
            }
        }
        .onAppear {
            // Initialize game state
        }
        .onReceive(NotificationCenter.default.publisher(for: 
                         UIResponder.keyboardWillShowNotification)) { _ in
            // Handle keyboard events or gestures here
        }
    }
}

struct GridView: View {
    let board: [[Int]]

    var body: some View {
        VStack(spacing: 8) {
            ForEach(0..<4) { row in
                HStack(spacing: 8) {
                    ForEach(0..<4) { col in
                        TileView(value: board[row][col])
                    }
                }
            }
        }
    }
}

struct TileView: View {
    let value: Int

    var body: some View {
        Text(value == 0 ? "" : "\(value)")
            .frame(width: 80, height: 80)
            .background(Color.gray.opacity(0.4))
            .cornerRadius(10)
            .font(.title)
            .foregroundColor(.black)
    }
}

код GameView с жестом DragGesture
struct GameView: View {
    @StateObject private var viewModel = GameViewModel()

    var body: some View {
        VStack {
            Text("2048")
                .font(.largeTitle)
                .padding()
            
            GridView(board: viewModel.board)
                .padding()
                .gesture(
                    DragGesture()
                        .onEnded { value in
                            let horizontalAmount = value.translation.width
                            let verticalAmount = value.translation.height
                            
                            if abs(horizontalAmount) > abs(verticalAmount) {
                                if horizontalAmount > 0 {
                                    viewModel.move(.right)
                                } else {
                                    viewModel.move(.left)
                                }
                            } else {
                                if verticalAmount > 0 {
                                    viewModel.move(.down)
                                } else {
                                    viewModel.move(.up)
                                }
                            }
                        }
                )
            
            Button(action: {
                viewModel = viewModel.resetGame() 
            }) {
                Text("Restart")
                    .font(.title)
                    .padding()
            }
        }
    }
}

Копируем полученный код в наш проект в Xcode:

Мы получили вполне себе работающий код генерации UI с необходимыми “заглушками” для логики игры 2048, которые придется наполнять кодом. 

ШАГ 2. Функция move()

Получим код для самой главной функции move() с помощью CharGPT:

Функция move (_ direction:Direction)
func move(_ direction: Direction) {
    var moved = false

    // Rotate the board so that we can treat each move as if it's a "left" move
    switch direction {
    case .up:
        rotateLeft()
    case .down:
        rotateRight()
    case .right:
        rotateRight()
        rotateRight()
    case .left:
        break
    }

    for i in 0..<4 {
        let compressedRow = compressRow(board[i])
        let mergedRow = mergeRow(compressedRow)
        let finalRow = compressRow(mergedRow)
        if finalRow != board[i] {
            moved = true
        }

        board[i] = finalRow
    }

    // Rotate the board back to its original orientation
    switch direction {
    case .up:
        rotateRight()
    case .down:
        rotateLeft()
    case .right:
        rotateRight()
        rotateRight()
    case .left:
        break
    }

    if moved {
        addNewTile()
        checkGameOver()
    }
}

func rotateLeft() {
    board = (0..<4).map { i in
        (0..<4).map { j in
            board[j][3 - i]
        }
    }
}

func rotateRight() {
    board = (0..<4).map { i in
        (0..<4).map { j in
            board[3 - j][i]
        }
    }
}
func compressRow(_ row: [Int]) -> [Int] {
    let filteredRow = row.filter { $0 != 0 }
    let zeros = Array(repeating: 0, count: 4 - filteredRow.count)
    return filteredRow + zeros
}
func mergeRow(_ row: [Int]) -> [Int] {
    var newRow = row
    for i in 0..<3 {
        if newRow[i] != 0 && newRow[i] == newRow[i + 1] {
            newRow[i] *= 2
            newRow[i + 1] = 0
        }
    }
    return newRow
}
Новый GameViewModel:
enum Direction {
    case up, down, left, right
}

class GameViewModel: ObservableObject {
    @Published var board: [[Int]]
    @Published var isGameOver = false

    init() {
        board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
        addNewTile()
        addNewTile()
    }

    func addNewTile() {
        var emptyTiles: [(Int, Int)] = []
        for row in 0..<4 {
            for col in 0..<4 {
                if board[row][col] == 0 {
                    emptyTiles.append((row, col))
                }
            }
        }
        if let randomTile = emptyTiles.randomElement() {
            board[randomTile.0][randomTile.1] = Bool.random() ? 2 : 4
        }
    }

    func move(_ direction: Direction) {
        var moved = false

        switch direction {
        case .up:
            rotateLeft()
        case .down:
            rotateRight()
        case .right:
            rotateRight()
            rotateRight()
        case .left:
            break
        }

        for i in 0..<4 {
            let compressedRow = compressRow(board[i])
            let mergedRow = mergeRow(compressedRow)
            let finalRow = compressRow(mergedRow)
            if finalRow != board[i] {
                moved = true
            }
            board[i] = finalRow
        }

        switch direction {
        case .up:
            rotateRight()
        case .down:
            rotateLeft()
        case .right:
            rotateRight()
            rotateRight()
        case .left:
            break
        }

        if moved {
            addNewTile()
            checkGameOver()
        }
    }

    func checkGameOver() {
        // Implement the logic to check if the game is over
        // This will involve checking if there are any valid moves left
    }

    func resetGame() {
        board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
        gameOver = false
        addNewTile()
        addNewTile()
    }

    private func rotateLeft() {
        board = (0..<4).map { i in
            (0..<4).map { j in
                board[j][3 - i]
            }
        }
    }

    private func rotateRight() {
        board = (0..<4).map { i in
            (0..<4).map { j in
                board[3 - j][i]
            }
        }
    }

    private func compressRow(_ row: [Int]) -> [Int] {
        let filteredRow = row.filter { $0 != 0 }
        let zeros = Array(repeating: 0, count: 4 - filteredRow.count)
        return filteredRow + zeros
    }

    private func mergeRow(_ row: [Int]) -> [Int] {
        var newRow = row
        for i in 0..<3 {
            if newRow[i] != 0 && newRow[i] == newRow[i + 1] {
                newRow[i] *= 2
                newRow[i + 1] = 0
            }
        }
        return newRow
    }
}

Круто!!
За 2 шага мы получили работающий вариант игры 2048. Перемещение и слияние плиток работает, но без анимации, кроме того, нет индикации об окончании игры и счета. Давайте сначала добавим анимацию плиток на игровом поле.

UI и анимация игры 2048

ШАГ 3. Подготовка модели к анимации перемещения плиток

Давайте проведем подготовку к анимации перемещения плиток и наделим наши плитки не только значением value, но и положением position плитки на игровом поле. Теперь нашу плитку будет моделировать структура struct Tile, в которой в качестве свойства плитки position используется структура struct Position { var row: int var col: Int}, a игровое поле представлено двухмерным массивом var board: [[Tile]]:

import SwiftUI

struct Position {
    var row: Int
    var col: Int
}

struct Tile {
    var value: Int
    var position: Position
}
Обновление GameViewModel в связи с использованием Tile:
import SwiftUI

enum Direction {
    case up, down, left, right
}

class GameViewModel: ObservableObject {
    @Published var tiles: [[Tile]] = []
    @Published var isGameOver = false

   init() {
        resetGame()
    }
    
   func resetGame() {
        isGameOver = false
        tiles = (0..<4).map { row in
                (0..<4).map { col in
                    Tile(value: 0, position: Position(row: row, col: col))
                }
            }
        addNewTile()
        addNewTile()
        }

    func addNewTile() {
        var emptyPositions: [Position] = []
        for row in 0..<4 {
            for col in 0..<4 {
                if tiles[row][col].value == 0 {
                    emptyPositions.append(Position(row: row, col: col))
                }
            }
        }
        if let randomPosition = emptyPositions.randomElement() {
            let newValue = Bool.random() ? 2 : 4
            tiles[randomPosition.row][randomPosition.col].value = newValue
        }
    }

    func move(_ direction: Direction) {
        var moved = false

        // Rotate the board so we can always handle the move as a "left" move
        switch direction {
        case .up:
            rotateLeft()
        case .down:
            rotateRight()
        case .right:
            rotateRight()
            rotateRight()
        case .left:
            break
        }

        for i in 0..<4 {
            let row = getRow(i)
            let compressedRow = compressRow(row)
            let mergedRow = mergeRow(compressedRow)
            if mergedRow != row {
                moved = true
                updateRow(i, with: mergedRow)
            }
        }

        switch direction {
        case .up:
            rotateRight()
        case .down:
            rotateLeft()
        case .right:
            rotateRight()
            rotateRight()
        case .left:
            break
        }

        if moved {
            addNewTile()
            checkGameOver()
        }
    }

    func checkGameOver() {
        // Implement the logic to check if the game is over
    }

    private func rotateLeft() {
        tiles = (0..<4).map { col in
            (0..<4).map { row in
                var tile = tiles[row][3 - col]
                 tile.position = Position(row: col, col: row)
                 return tile
            }
        }
    }

    private func rotateRight() {
        tiles = (0..<4).map { col in
            (0..<4).map { row in
                 var tile = tiles[3 - row][col]
                 tile.position = Position(row: col, col:  row)
                 return tile
            }
        }
    }

    private func getRow(_ index: Int) -> [Tile] {
        return tiles[index]
    }

    private func updateRow(_ index: Int, with newRow: [Tile]) {
        for col in 0..<4 {
            tiles[index][col] = newRow[col]
        }
    }

    private func compressRow(_ row: [Tile]) -> [Tile] {
        let nonZeroTiles = row.filter { $0.value != 0 }

       // Guard to check if we need to compress
       guard !nonZeroTiles.isEmpty, nonZeroTiles.count != 4,
          !(nonZeroTiles.count == 1 && nonZeroTiles[0].position.col == 0) 
        else {
            // If the row is already in a compressed state, return it as is
            return row
        }

        // Create new row with non-zero tiles and update their positions
        let newRow: [Tile] = nonZeroTiles.enumerated().map { (index, tile) in
            var updatedTile = tile
            updatedTile.position = 
                               Position(row: tile.position.row, col: index)
            return updatedTile
        }

        // Add zeros to the end of the row with updated positions
        let zeros = (newRow.count..<row.count).map { colIndex in
            Tile(value: 0, position: 
                 Position(row: row[0].position.row, col: colIndex))
        }

        return newRow + zeros
    }

    private func mergeRow(_ row: [Tile]) -> [Tile] {
        var newRow = row
        
       let nonZeroTiles = row.filter { $0.value != 0 }
        
       // If the row has less than 2 tiles return it as is
        guard nonZeroTiles.count > 1 else {
            return row
        }

        for i in 0..<row.count - 1 {
            if newRow[i].value != 0 && newRow[i].value == newRow[i + 1].value {
                
                // Merge tiles
                newRow[i].value *= 2
                
                // New zero tile on i + ! position
                newRow[i + 1] = Tile(value: 0, position: 
                          Position(row: newRow[i].position.row, col: i + 1))
            }
        }

        // Compress the row after merging
        return compressRow(newRow)
    }
}

GameView c TileView
struct GameView: View {
    @StateObject private var viewModel = GameViewModel()

    var body: some View {
        VStack {
            Text("2048")
                .font(.largeTitle)
                .padding()
            
            GridView(tiles: viewModel.tiles)
                .padding()
                .gesture(
                    DragGesture()
                        .onEnded { value in
                            let horizontalAmount = value.translation.width
                            let verticalAmount = value.translation.height
                            
                            if abs(horizontalAmount) > abs(verticalAmount) {
                                if horizontalAmount > 0 {
                                    viewModel.move(.right)
                                } else {
                                    viewModel.move(.left)
                                }
                            } else {
                                if verticalAmount > 0 {
                                    viewModel.move(.down)
                                } else {
                                    viewModel.move(.up)
                                }
                            }
                        }
                )
            
            Button(action: {
                viewModel.resetGame()
            }) {
                Text("Restart")
                    .font(.title)
                    .padding()
            }
        }
    }
}

struct GridView: View {
    let tiles: [[Tile]]

    var body: some View {
        VStack(spacing: 8) {
            ForEach(0..<4) { row in
                HStack(spacing: 8) {
                    ForEach(0..<4) { col in
                        TileView(tile: tiles[row][col])
                    }
                }
            }
        }
    }
}

struct TileView: View {
    let tile: Tile

    var body: some View {
        Text(tile.value == 0 ? "" : "\(tile.value)")
            .frame(width: 80, height: 80)
            .background(Color.gray.opacity(0.4))
            .cornerRadius(10)
            .font(.title)
            .foregroundColor(.black)
    }
}

Используем код в нашем проекте и в GameViewModel получаем ошибку:

Спрашиваем ChatGPT, как её исправить:

struct Position:Equatable {
    var row: Int
    var col: Int
}

struct Tile: Equatable {
    var value: Int
    var position: Position
}
struct Tile: Equatable {
    var value: Int
    var position: Position

    // Manually implement Equatable conformance
   /* static func == (lhs: Tile, rhs: Tile) -> Bool {
        return lhs.value == rhs.value &&
               lhs.position == rhs.position
    }*/
}

struct Position: Equatable {
    var row: Int
    var col: Int
}

Мы использовали соответствие структуры Position протоколу Equatable, которое выполняется Swift автоматически, и ручную реализацию c использованием только свойство value, что необходимо для логики игры 2048 :

struct Tile: Equatable {
    var value: Int
    var position: Position

    // 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
}

Все работает как и прежде, но с новой структурой Tile со свойством position:

Однако для анимации нам нужно работать с изображением плитки TileView на игровой доске, и первое, что нам нужно сделать, - это добавить позиционирование плитки TileView на игровой доске с помощью модификатора .position, используя свойство position самой модели Tile.

ШАГ 4. Модификатор .position для TileView

код TileView и GridView
import SwiftUI

// Define the TileView
struct TileView: View {
    let tile: Tile

    var body: some View {
        Text(tile.value == 0 ? "" : "\(tile.value)")
            .frame(width: 80, height: 80)
            .background( Color.gray.opacity(0.4))
            .cornerRadius(10)
            .font(.title)
            .foregroundColor(.black)
            .position(getTilePosition())
    }

    private func getTilePosition() -> CGPoint {
        let tileSize: CGFloat = 80 // Adjust based on tile size and padding
        let spacing: CGFloat = 8 // Space between tiles

        let x = 
            CGFloat(tile.position.col) * (tileSize + spacing) + tileSize / 2
        let y = 
            CGFloat(tile.position.row) * (tileSize + spacing) + tileSize / 2

        return CGPoint(x: x, y: y)
    }
}

// Define the GridView to use TileView
struct GridView: View {
    let tiles: [[Tile]]

    var body: some View {
        ZStack {
            ForEach(tiles.flatMap { $0 }, id: \.position) { tile in
                TileView(tile: tile)
            }
        }
        .frame(width: 4 * 80 + 3 * 8, height: 4 * 80 + 3 * 8) // Adjust frame size
    }
}

ШАГ 6. Протокол Identifiable для ForEach

Ранее у нас был такой код для  GridView:

struct GridView: View {
    let tiles: [[Tile]]
    var body: some View {
        VStack(spacing: 8) {
            ForEach(0..<4) { row in
                HStack(spacing: 8) {
                    ForEach(0..<4) { col in
                        TileView(value:tiles [row][col])
                    }
                }
            }
        }
    }
}

Теперь мы получили новый код GridView:

// Define the GridView to use TileView
struct GridView: View {
    let tiles: [[Tile]]

    var body: some View {
        ZStack {
            ForEach(tiles.flatMap { $0 }, id: \.position) { tile in
                TileView(tile: tile)
            }
        }
        .frame(width: 4 * 80 + 3 * 8, height: 4 * 80 + 3 * 8) // Adjust frame size
    }
}

Заметьте, как только мы добавили модификатор .position для TileView, необходимость в сетке, состоящей из вложенных ForEach, пропала. ChatGPT четко это уловил и ”вытянул“ 2D  массив в 1D массив с помощью функции высшего порядка flatMap и для единственного ForEach использовал этот массив, полагая, что свойство positionплитки Tile не только определяет местоположение плитки TileView на игровой доске, но однозначно идентифицирует саму плитку Tile.

Но это не так, так как  позиция position плитки Tile с течением игры меняется, хотя плитка остается той же самой, так что position вовсе не является нужным нам идентификатором уникальности плитки Tile.

// Define the GridView to use TileView
struct GridView: View {
    let tiles: [[Tile]]

    var body: some View {
        ZStack {
            ForEach(tiles.flatMap { $0 }) { tile in
                TileView(tile: tile)
            }
        }
        .frame(width: 4 * 80 + 3 * 8, height: 4 * 80 + 3 * 8) // Adjust frame size
    }
}

Нам нужна какая-то другая вещь, которая идентифицирует плитку навсегда и однозначно. Неважно, что произойдет с этой плиткой, неважно как сильно она поменяется, мы знаем, что это та же самая плитка, нашForEach всегда будет точно знать, с какой плиткой он имеет дело. Это важно для анимации.

Но как только мы уберем  id: \.position, мы получаем ошибку:

Давайте спросим ChatGPT, что нам делать с этой ошибкой:

struct Tile: Equatable, Identifiable {
// Manually
   static func == (lhs: Tile, rhs: Tile) -> Bool {
        return lhs.value == rhs.value
    }
    
    var value: Int
    var position: Position
    var id = UUID()  // This provides a unique identifier for each tile
}

struct Position: Equatable {
    var row: Int
    var col: Int
}
// Define the GridView to use TileView
struct GridView: View {
    let tiles: [[Tile]]

    var body: some View {
        ZStack {
            ForEach(tiles.flatMap { $0 }) { tile in
                TileView(tile: tile)
            }
        }
        .frame(width: 4 * 80 + 3 * 8, height: 4 * 80 + 3 * 8) // Adjust frame size
    }
}

Шаг 5. Анимация

TileView с неявной анимацией и модификатором перехода .transition
import SwiftUI

struct TileView: View {
    let tile: Tile
    let tileSize: CGFloat
    let padding: CGFloat

    var body: some View {
        RoundedRectangle(cornerRadius: padding)
            .fill(Color.colorForTile(tile.value))
            .frame(width: tileSize, height: tileSize)
            .overlay(
                Text(tile.value > 0 ? "\(tile.value)" : "")
                    .font(.largeTitle)
                    .foregroundColor(.black)
            )
            .position (getTilePosition())
            .animation(.easeInOut(duration: 0.2), value: tile.position)
            .transition(.scale)
    }

    private func getTilePosition() -> CGPoint {
      let x = CGFloat(tile.position.col) * (tileSize + padding) + tileSize / 2
      let y = CGFloat(tile.position.row) * (tileSize + padding) + tileSize / 2
        return CGPoint(x: x, y: y)
    }
}

extension Color {
   static func colorForTile(_ value: Int) -> Color {
        switch value {
        case 0: return Color(UIColor.systemGray5)
        case 2: return Color(UIColor.systemGray4)
        case 4: return Color.orange
        case 8: return Color.red
        case 16: return Color.purple
        case 32: return Color.blue
        case 64: return Color.green
        case 128: return Color.yellow
        case 256: return Color.pink
        case 512: return Color.brown
        case 1024: return Color.cyan
        case 2048: return Color.indigo
        default: return Color.mint
        }
    }
}

GameView c явной анимацией withAnimation
struct GameView: View {
    @StateObject private var viewModel = GameViewModel()
    let tileSize: CGFloat = 80
    let padding: CGFloat = 8
    var body: some View {
        VStack {
            Text("2048")
                .font(.largeTitle)
                .padding()
            
            GridView(tiles: viewModel.tiles, tileSize: tileSize, 
                                             padding: padding)
                .gesture(
                    DragGesture()
                        .onEnded { value in
                            withAnimation(.easeInOut) {
                                handleSwipe(value: value)
                            } 
                        }
                )
            
            Button(action: {
              withAnimation(.easeInOut) {
                  viewModel.resetGame()
                }
            }) {
                Text("Restart")
                    .font(.title2)
                    .padding()
            }
        }
    }
    
    // Handle swipe gesture and trigger game actions
    private func handleSwipe(value: DragGesture.Value) {
        let threshold: CGFloat = 20
        let horizontalShift = value.translation.width
        let verticalShift = value.translation.height
        
        if abs(horizontalShift) > abs(verticalShift) {
            if horizontalShift > threshold {
                viewModel.move(.right)
            } else if horizontalShift < -threshold {
                viewModel.move(.left)
            }
        } else {
            if verticalShift > threshold {
                viewModel.move(.down)
            } else if verticalShift < -threshold {
                viewModel.move(.up)
            }
        }
    }
}

Скрытый текст
// Define the GridView to use TileView
struct GridView: View {
    let tiles: [[Tile]]
    let tileSize : CGFloat
    let padding : CGFloat
    
    var body: some View {
       ZStack {


         // Background grid
            VStack(spacing: padding) {
               ForEach(0..<4) { row in
                   HStack(spacing: padding) {
                       ForEach(0..<4) { col in
                           RoundedRectangle(cornerRadius:padding)
                               .fill(Color.colorForTile(0))
                               .frame(width: tileSize, height: tileSize)
                       }
                   }
               }
           }

            // Foreground tiles (only non-zero values)
             ForEach(tiles.flatMap { $0 }.filter { $0.value != 0 }){ tile in
                TileView(tile: tile, tileSize: tileSize, padding: padding)
             }
        }
        .frame(width: 4 * tileSize + 3 * padding, 
               height: 4 * tileSize +  3 * padding) // Adjust frame size
    }

}

Вот как работает этот код:

A вот в режиме   “Медленной Анимации” (Slow Animation) :

Мы видим, что появление новых плиток анимируется из середины (.center), и это выглядит не совсем хорошо, нам бы хотелось, чтобы появление новых плиток  анимировалось “по месту” плиток в игровом поле.

Усовершенствованный переход .transition (.scale)

Давайте спросим, как добиться этого у ChatGPT:

код TileView c .transition (.scale) и .transition(.offset):
struct TileView: View {
    let tile: Tile
    let tileSize: CGFloat
    let padding: CGFloat
    
   var body: some View {
       let tilePosition = getTilePosition()
       
        RoundedRectangle(cornerRadius:padding)
            .fill(Color.colorForTile(tile.value))
            .frame(width: tileSize, height: tileSize)
            .overlay(
                Text(tile.value > 0 ? "\(tile.value)" : "")
                    .font(.largeTitle)
                    .foregroundColor(.black)
            )
            .position(tilePosition)
            .animation(.easeInOut(duration: 0.2), value: tile.position) 
             .transition(.scale(scale: 0.12).combined (with: .offset( 
                            x: tilePosition.x - 2.0 * tileSize,
                             y: tilePosition.y - 2.0 * tileSize)))
    }
    
    private func getTilePosition() -> CGPoint {
      let x = CGFloat(tile.position.col) * (tileSize + padding) + tileSize / 2
      let y = CGFloat(tile.position.row) * (tileSize + padding) + tileSize / 2
        
        return CGPoint(x: x, y: y)
    }
}

Вот как работает этот код:

A вот в режиме   “Медленной Анимации” (Slow Animation) :

Следующие шаги я не буду рассматривать здесь, с ними можно подробно познакомиться в посте :

ШАГ 6.  Цвета специфические для игры 2048

Шаг 7.  Счет score для игры 2048

ШАГ 8.  Окончание игры 2048. 

ШАГ 9.  Оптимальное направление жеста для игры 2048.

Результат работы приложения после применения вышеуказанных шагов:

Теперь, когда на любом этапе игры 2048 мы можем определить оптимальное направление перемещения плиток с помощью  bestMoveDirection(), мы можем заменить ручной swipe жест на автоматический запуск перемещение плиток в оптимальном направлении. и тем самым реализовать своего рода "жадный" (greedy) ИИ (AI) алгоритм в игре 2048. 

Искусственный интеллект (AI) в игре 2048

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

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

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

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

View, обновляющее счетчик каждую секунду
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 

код GameView c Timer.publish и .omReceive
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)
    }
}

A вот наш UI:

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

ШАГ 12. Алгоритм 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
}
код алгоритмв expectimax:
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
    }

код функции expectimaxBestMove (
// MARK: - Expectimax
    func expectimaxBestMove (depth: Int, matr [[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
    }

код GameViewModel
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...
}

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 (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 поиск оптимального хода:

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

функция monotonicity (grid: )
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)
    }

функция smoothness (grid: )
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: )
func emptyTileCount(_ board: [[Tile]]) -> Double {
    return Double(board.flatMap { $0 }.filter { $0.value == 0 }.count)
}

функция maxTileInCorner(board: ) -> Double
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()

функция evaluate(board:)
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
}

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

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

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

[15, 14, 13, 12]
[8,  9,  10, 11]
[7,  6,  5,  4]
[0,  1,  2,  3]
функция snakeHeuristic(board:)
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
}

функция evaluateBoard ( board: )
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)
     }

[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]
функция snakeHeuristic(_ board: )
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

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

функция monteCarloSearch (board: simulations: depth: )
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
    }

функция randomGame(board: depth:)
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)
    }

функция evaluateBoard( board: )
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.
}

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

код biasedRandomGame(direction: board:depth: Int)
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
    }

код randomGameWithEarlyStopping(board: depth: maxBadMoves:)
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)

код monteCarloSearchWithDynamicSimulations(board: maxSimulations: depth:
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
}

код runSimulationsParallel(board: direction: simulations: depth: )
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)
}

код evaluateBoard(board: )
func evaluateBoard(_ board: [[Tile]]) -> Double {
    let smoothness = calculateSmoothness(board)
    let monotonicity = calculateMonotonicity(board)
    let emptySpaces = Double(getEmptyTiles(board).count)
    
    return smoothness + monotonicity + emptySpaces * 2
}

Давайте выберем параллельное симулирование и просим ChatGPT реализовать предложенную ранее функцию runSimulationsParallel(board: direction: simulations: depth: ) с помощью более современных async await:

ШАГ 17. Сделай рефакторинг 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)
}

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

код expectimaxAsyn (grid: depth: isAITurn)
 // 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, flatMap, compactMap, filter, allSatisfy) в Swift и всюду предлагает их, иногда в самых неожиданных ситуациях и самым изобретательным образом, что приятно удивляет.

  4. Прекрасно владеет архитектурой MVVM (возможно, и другими, просто не пробовала), предлагая как незащищенную модель, когда ViewModel и Model в одном классе (с протоколом ObservableObject или новым макросом @Observable), так и классическую защищенную модель: Model отдельно от ViewModel и View. Легко переходит от одной к другой.

  5. Расшифровывает все ошибки и даёт дельные советы по их исправлению.

  6. В большинстве случаев запоминает и хранит наработанный в процессе взаимодействия код для поставленной задачи на протяжении почти всей сессии и позволяет ссылаться на различные его этапы.

  7. Хорошо рефакторит код.

  8. Генерирует Unit тесты с использованием XCTest.

  9. Проявляет фантастическую эрудицию в части ИИ алгоритмов для игр типа 2048.

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

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

Недостатки:

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

  • Иногда "увиливает" от прямо поставленного вопроса.

  • Часто даёт код предыдущей версии: протокол ObservableObject вместо макроса @Observable,GCD (Grand Central Dispatch) вместо async await, но стоит на это указать и ChatGPT великолепно выполняет рефакторинг кода и объясняет различие между новым синтаксисом и старым.

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

Комментарии (8)


  1. RodionGork
    26.10.2024 12:18

    Было бы любопытно сделать тот же эксперимент с какой-либо менее затасканной игрой - в смысле чтобы посмотреть как ChatGPT не просто обворовывает GitHub, а пытается "создать" алгоритм самостоятельно.


    1. WildGreyPlus Автор
      26.10.2024 12:18

      Кстати, что странно, я не заметила, чтобы chatGPT обворовывает GitHub. Уж поверьте, я все просмотрела. Код оригинальный, особенно в части concurrency. Что приятно удивило.


    1. WildGreyPlus Автор
      26.10.2024 12:18

      А "затасканная" задача была взята намеренно, чтобы не париться с контекстом. Уж не знаю, что где взял ChatGPT, но задачу понимает абсолютно точно. Я пробовала еще физические задачи : типа скользит шарик по сфере и в какой-то точке отрывается и начинает падать свободно - прекрасно рисует траекторию.


  1. Solovej
    26.10.2024 12:18

    И не лень же Вам было столько скриншотов делать :)


    1. WildGreyPlus Автор
      26.10.2024 12:18

      Абсолютно не лень. Азарт. Попробуйте, взять что-нибудь знакомое и проделать то же самое с известной вам задачей. Вам понравится. Под конец уже совсем наглеешь, а ChatGpt все равно выдает очень приличный код.


  1. Bardakan
    26.10.2024 12:18

    Проверил пару первых попавшихся ссылок - или просто нерабочие, или ведут на несуществующие материалы


    1. WildGreyPlus Автор
      26.10.2024 12:18

      Извините, в качестве ссылки на Monte Carlo дала ссылку на medium.com, который в России не открывается. Исправлено. Больше не нашла ссылок, которые не открываются. Если не составит труда, подскажите, какие не открываются в России - заменю.


  1. dron88
    26.10.2024 12:18

    За такой расширенный материал и старания, отдельно ставлю лайк.