iOS приложения игры 2048 в SwiftUI  с ChatGPT 4-o. Часть 1. Введение. Логика игры 2048.

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

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

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

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

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

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

Рис.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 автоматическим запуском.

На первом этапе получим такое iOS приложение игры 2048 без анимации и без счета, a на втором этапе —  iOS приложение с анимацией и счетом:

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

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

Рис.3  Слева — алгоритм Expectimax в действии, справа — наилучший результат алгоритма Expectimax.

Рис.4  Слева — алгоритм Monte Carlo в действии, справа — наилучший результат алгоритма Monte Carlo.

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

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

Логика игры 2048

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

ШАГ 1. Начало

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
    }

    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
    }
}
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 = viewModel.resetGame()
            }) {
                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)
    }
}
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, которые придется наполнять кодом. 

Начнем с метода resetGame () в  классе GameViewModel, который будет осуществлять перезапуск игры 2048. Опять обращаемся к ChatGPT:

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

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

    func resetGame() {
        // Reset the board to the initial state
        board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
        gameOver = false
        addNewTile()
        addNewTile()
    }
}
struct Gameiew: 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.resetGame()
            }) {
                Text("Restart")
                    .font(.title2)
                    .padding()
            }
        }
    }
}

В результате в коде ошибок нет, мы можем кликать на кнопке «Restart» и получать каждый раз новые начальные плитки:

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

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

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

ШАГ 4. Подготовка к анимации

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

import SwiftUI

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

struct Tile {
    var value: Int
    var position: Position
}
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)
    }
}
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 {
    var row: Int
    var col: Int
}

struct Tile: Equatable {
    var value: Int
    var position: Position
}
if finalRow != row {
    moved = true
    updateRow(i, with: finalRow)
}

Но мы получили ещё одну ошибку:

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

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 автоматически, и ручную реализацию, используя только свойство value:

import SwiftUI
struct Position: Equatable {
    var row: Int
    var col: Int
}
struct Tile: Equatable { 
   // Manually
   static func == (lhs: Tile, rhs: Tile) -> Bool {
        return lhs.value == rhs.value   
    }
    
    var value: Int
    var position: Position
}

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

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

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

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 = 10 // 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 * 10, height: 4 * 80 + 3 * 10) // 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.

Так что в GridView в ForEach мы убираем id: \.position:

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

ШАГ 7. Функция canMoveLeft — строка может перемещаться влево?

func canMoveLeft(_ row: [Tile]) -> Bool {
    // Check for non-zero tiles that can slide left (i.e., zeros before non-zero tiles)
    for i in 1..<row.count {
        if row[i].value != 0 && row[i - 1].value == 0 {
            return true // A tile can move left because there's space
        }
    }

    // Check for adjacent equal non-zero tiles that can be merged
    for i in 0..<row.count - 1 {
        if row[i].value != 0 && row[i].value == row[i + 1].value {
            return true // A tile can merge with its neighbor
        }
    }

    // If no movement or merging is possible, the row cannot move left
    return false
}
let row = [
    Tile(value: 2, position: Position(row: 0, col: 0)),
    Tile(value: 2, position: Position(row: 0, col: 1)),
    Tile(value: 0, position: Position(row: 0, col: 2)),
    Tile(value: 4, position: Position(row: 0, col: 3))
]

let canMove = canMoveLeft(row)
print(canMove)  // Output: true (because the two '2' tiles can be merged)

Рефакторинг canMoveLeft с функциями высшего порядка

Давайте попросим ChatGPT использовать функции высшего порядка в функции canMoveLeft:

func canMoveLeft(_ row: [Tile]) -> Bool {
    // Condition 1: Check if any tile can slide left (i.e., a non-zero tile has a zero before it)
    let canSlide = 
          zip(row.dropFirst(), row).contains { $0.value != 0 && $1.value == 0 }

    // Condition 2: Check if any adjacent tiles can be merged
    let canMerge = 
       zip(row.dropFirst(), row).contains { $0.value != 0 && $0.value == $1.value }

    return canSlide || canMerge
}
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
            }
        // Iterate through each row and apply sliding logic
        for i in 0..<4 {
            let row = tiles[i]
            
            // Use canMoveLeft to check if the row can move or merge
            if canMoveLeft(row) {

                let compressedRow = compressRow(row)
                let mergedRow = mergeRow(compressedRow)
                if mergedRow != row {
                    moved = true
                    tiles[i] = megredRow
                }
            }
        }
        
        // Restore the board's orientation based on the direction
            switch direction {
            case .up:
                rotateRight()
            case .down:
                rotateLeft()
            case .right:
                rotateRight()
                rotateRight()
            case .left:
                break
            }
        
            if moved {
                addNewTile()
                checkGameOver()
            }
        }

ШАГ 8. Корреция функции mergeRow 

Сначала нам нужно понять, как выглядит функция mergeRow после добавления позиции position для плитки Tile:

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

Замечания относительно функции mergeRow

В нашем случае в процессе анимации мы действительно хотим видеть на UI “поглощение” плитки newRow[i] плиткой newRow[i + 1], то есть плитка newRow[i + 1] как бы “наезжает” на newRow[i] и оказывается на месте i , поэтому индексу i будет соответствовать id плитки newRow[i + 1]. Что касается индекса i + 1, то на этом месте будет плитка с нулевым значением value, и она тут же попадет в пул плиток с нулевыми значениями,  из которых случайным образом выбирается следующая новая плитка Tile. Плитка newRow[i + 1] должна получить именно новый id равный UUID(),  так как ее id уже занят, a не присваивать ей id плитки newRow[i] , что может привести к ненужную анимации перемещения уже имеющейся плитки.

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
                
                // Change the id
                newRow[i].id = newRow[i + 1].id
                
                // 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)
    }

С таким кодом в результате все работает с TileView, но нет анимации:

Заключение

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