iOS приложения игры 2048 в SwiftUI  с ChatGPT 4-o. Часть 2. Анимация и UI.

В прошлом посте «iOS приложение игры 2048 в SwiftUI с ChatGPT 4-o. Часть 1. Логика игры» показано, как реализовать логику игры 2048 c помощью ChatGPT. В этом посте мы рассмотрим проектирование UI игры 2048 с помощью ChatGPT и особое внимание уделим анимации перемещения плиток на игровой доске. Код находится на Github.

Анимация и UI

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

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)
            .scaleEffect(tile.value > 0 ? 1 : 0)
            .animation(.spring(), value: tile.value)
    }

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

}

Результаты работы кода:

Замечания к полученной анимации:

  1. TileView изменился: теперь не просто Text, a прямоугольник RoundedRectangle, на который накладывается Text(tile.value > 0 ? "\(tile.value)" : ""). Это сделано для того, чтобы плитка с нулевым значением value == 0 не потеряла пространство.
  2. Мы видим анимацию позиции плиток, то есть модификаторы .position и .animation(.easeInOut(duration: 0.2), value: tile.position) работают, но мы не видим анимацию изменения масштаба плитки, когда она становится ненулевой или наоборот, то есть модификаторы .scaleEffect(tile.value > 0 ? 1 : 0) и  .animation(.spring(), value: tile.value)’  не работают.  Это особенно отчетливо видно при отладке, когда задается режим “Медленной Анимации” (Slow Animation) :

Это происходит потому, что анимация начинается только ПОСЛЕ того, как TileView появляется на экране. В нашем случае, если значение value меняется с 0 на что-то отличное от 0, этот TileView просто “выходит” на экран без всякой анимации, a если значение value меняется на 0, то TileView исчезает с экрана, и в этом случае тоже нет никакой анимации. Для анимации такого рода прихода / ухода на / с экран используются “переходы” в виде модификатора .transition. В данном случае действует ‘переход” по умолчанию transition(.opacity).

Поэтому после повторного запроса кода TileView, ChatGPT убирает  модификаторы .scaleEffect(tile.value > 0 ? 1 : 0) и  .animation(.spring(), value: tile.value) и заменяет их на transition(.scale), что работает при появлении на экране или ухода с экрана Views:

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

Нужна ли явная анимация withAnimation для жеста .gesture

struct ContentView: 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 { // animation
                                handleSwipe(value: value) // swipe handler
                            }
                        }
                )
            Button(action: {
                withAnimation {   // animation
                     viewModel.resetGame()
               }
            }) {
                Text("Restart")
                    .font(.title2)
                    .padding()
            }
        }    }
    
    // Handle swipe gesture and trigger game actions
    private func handleSwipe(value: DragGesture.Value) {. . .}
}

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

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

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

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

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

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

Как видите здесь есть анимация и перемещения плиток и анимация масштабирования плитки при её появлении плиток на игровой доске.

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

import SwiftUI

extension Color {
   
    static func colorForTile(_ value: Int) -> Color {
    switch value {
      case 0:
        return Color(hex: "#CDC1B4") //  color for empty 
      case 2:
        return Color(hex: "#EEE4DA") // Light beige
      case 4:
        return Color(hex: "#EDE0C8") // Beige
      case 8:
        return Color(hex: "#F2B179") // Light orange
      case 16:
        return Color(hex: "#F59563") // Orange
      case 32:
        return Color(hex: "#F67C5F") // Darker orange
      case 64:
        return Color(hex: "#F65E3B") // Dark orange-red
      case 128:
        return Color(hex: "#EDCF72") // Yellow
      case 256:
        return Color(hex: "#EDCC61") // Darker yellow
      case 512:
        return Color(hex: "#EDC850") // Gold
      case 1024:
        return Color(hex: "#EDC53F") // Dark gold
      case 2048:
        return Color(hex: "#EDC22E") // Bright gold
     default:
        return Color(hex: "#CDC1B4") // Default color (for empty or non-standard tiles)
    }
  }
}
import SwiftUI

extension Color {
  init(hex: String) {
    let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
    var int: UInt64 = 0
    Scanner(string: hex).scanHexInt64(&int)
    let a, r, g, b: UInt64
    switch hex.count {
      case 3: // RGB (12-bit)
           (a, r, g, b) = 
              (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
      case 6: // RGB (24-bit)
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
      case 8: // ARGB (32-bit)
          (a, r, g, b) = 
               (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (255, 0, 0, 0)
        }

        self.init(
            .sRGB,
            red: Double(r) / 255,
            green: Double(g) / 255,
            blue: Double(b) / 255,
            opacity: Double(a) / 255
        )
    }
   
    static func colorForTile(_ value: Int) -> Color {. . .}
}
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(tile.value > 4 ? .white : .black) // Adjust text color based on tile value
            )
            .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)
    }
}
// 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)
                       }
                   }
               }
           }
            .padding()
            .background(Color(hex: "#BBADA0"))

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

Замечания к полученному коду:

Мы можем разместить цвета фона игрового поля Color(hex: "#BBADA0") и пустой плитки Color(hex: "#DC1B4") в расширении extension Color для дальнейшего использования в стилизации UI.

Расширение extension Color:

extension Color {
    static let colorBG  = Color(hex: "#BBADA0")
    static let colorEmpty   = Color(hex: "#CDC1B4")
    
  // Other code for Color extension 
 // .  .  .  .  .  .  .  .  .  .  .  .  .  .

ШАГ 10. Изменение вероятности появления новой плитки со значениями value 2 и 4

Если мы посмотрим на код функции  addNewTile() добавления новой плитки на игровое поле tiles, то увидим, что вероятность того, что это будет плитка со значением value равным 2 будет p = 0.5:

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

В классической игре 2048 эта вероятность равна  p = 0.9. Давайте попросим ChatGPT сгенерировать соответствующий этому значению вероятности p код:

func addNewTile() {
    let emptyPositions = tiles.flatMap { $0 }.filter { $0.value == 0 }

    // Check if there are no empty tiles available
    guard !emptyPositions.isEmpty else {
        return
    }

    // Select a random empty position
    let randomIndex = Int.random(in: 0..<emptyPositions.count)
    let randomPosition = emptyPositions[randomIndex].position

    // Determine whether to place a 2 or a 4, with a 90% chance of placing a 2
    let newValue = Double.random(in: 0..<1) < 0.9 ? 2 : 4

    // Update the tiles array with the new tile
    tiles[randomPosition.row][randomPosition.col] = 
                             Tile(value: newValue, position: randomPosition)
}

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

class GameViewModel: ObservableObject {
     @Published var tiles: [[Tile]] = []
     @Published var isGameOver = false
     @Published var score: Int = 0
    
    init() {
        resetGame()
    }
    
   func resetGame() {
        score = 0
        isGameOver = false
        tiles = (0..<4).map { row in
                (0..<4).map { col in
                    Tile(value: 0, position: Position(row: row, col: col))
                }
            }
        addNewTile()
        addNewTile()
        }
        
        
    // Other functions such as addNewTile(), rotateLeft(), etc.
    // .  .  .  .  .  .  .  .  .  .  .  .  .  .
}
  private func mergeRow(_ row: [Tile]) -> [Tile] {
        var newRow = row
        var scoreToAdd = 0
       
        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
                scoreToAdd += newRow[i].value

                // 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))
            }
        }
        // Update the score
          score += scoreToAdd
        
        // Compress the row after merging
        return compressRow(newRow)
     }
struct ContentView: View {
    
    @StateObject private var viewModel = GameViewModel()
    let tileSize: CGFloat = 80
    let padding: CGFloat = 8
    
    var body: some View {
        VStack {
            Text("2048")
                .bold()
                .font(.largeTitle)
                .padding()
            
           HStack {
                // Score Display
                Text("Score: \(viewModel.score)")
                    .monospacedDigit()
                    .font(.title)
                    .foregroundColor(.accentColor)
                    .padding()
                Spacer()
            }
            
            GridView(tiles: viewModel.tiles, tileSize: tileSize, 
                                             padding: padding)
                .gesture(
                    DragGesture()
                        .onEnded { value in
                           withAnimation {
                                handleSwipe(value: value) // swipe handler
                           }
                        }
                )
            
            // Reset Button
            Button (action:  {
                withAnimation {
                    viewModel.resetGame()
                }
            }){
                Text("Restart")
                    .bold()
                    .font(.title)
             }
            .padding()
        }
    }
    
    // Handle swipe gesture and trigger game actions
    private func handleSwipe(value: DragGesture.Value)  {. . .}  
}

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

Раз мы умеем вычислять увеличение счета scoreToAdd при выполнении жеста в определенном направлении (.up, .down, .left, .right), мы можем определить оптимальное направление, в котором увеличение счета scoreToAdd будет максимальным.
Мы можем использовать новый независимый экземпляр нашей GameViewModel :

 var gameView = GameViewModel()), 

установить ему текущее игровое поле

gameView.tiles = tiles 

и выполнить перемещение плиток

slide (_ direction: Direction) 

без добавления новой плитки, но с возвращением увеличения счета score и сообщения moved  о том, что перемещение плиток действительно произошло. Это в некотором смысле сделает наш код более гибким и позволит определить направление direction с максимальным приростом счета score. Давайте так и поступим.

Функция slide (_ direction:Direction)

Для начала мы модифицируем уже существующую функцию  move:

func move (_ direction: Direction)

которая и двигает плитки и добавляет случайным образом новую плитку, так, чтобы выделить из нее функцию slide:

func slide(_ direction: Direction) -> (moved: Bool, score: Int) 

… которая осуществляет только  сдвиг плиток на игровом поле в  нужном направлении, параллельно информируя о том, было ли перемещение и сколько дополнительных очков мы получили в наш счет в виде кортежа (moved: Bool, score: Int), состоящего из Bool значения moved (то есть было ли перемещение плиток и нужно ли добавлять новую плитку) и увеличения счета score:Int при выполненном перемещении плиток в заданном направлении direction.  Такая slide функция очень легко впишется в новую версию функции move и даст возможность симулировать перемещение плиток для расчета оптимального направления:

func bestMoveDirection ( ) -> Direction 

Давайте попросим об этом ChatGPT:

extension GameViewModel {
    func slide(_ direction: Direction) -> (moved: Bool, score: Int) {
        var moved = false
        var totalScore = 0

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

        // Process each row
        for i in 0..<4 {
            let row = tiles[i]
            if canMoveLeft(row) {
               let compressedRow = compressRow(row)
               let (mergedRow, rowScore) = mergeRow(compressedRow)
               totalScore += rowScore

               if mergedRow != row {
                  moved = true
                  tiles [i] = mergedRow
               }
             }
         }
        // Rotate back to the original orientation
        switch direction {
        case .up:
            rotateRight()
        case .down:
            rotateLeft()
        case .right:
            rotateRight()
            rotateRight()
        case .left:
            break
        }

        return (moved, totalScore)
    }

    // Helper functions (rotateLeft, rotateRight, compressRow, mergeRow)
    // These functions should already be defined in your GameViewModel
    // If not, please define them as per your existing code.
}
let (moved, score) = gameViewModel.slide(.left)
if moved {
    // Optionally call addNewTile() here, if needed
}
func move(_ direction: Direction) {
    let (moved, score) = slide(direction)
    
    if moved {
        self.score += score
        addNewTile()
    }
}
private func mergeRow(_ row: [Tile]) -> ([Tile], Int) {
        var newRow = row
        var scoreToAdd = 0
        
        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, scoreToAdd)
        }
        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
                scoreToAdd += newRow[i].value
                
                // 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), scoreToAdd)
    }

Необходимо удостовериться, что функция  slide работает правильно. Ранее мы не могли проверить правильность работы функции  move из-за появления случайных значений. Теперь нам такая возможность предоставляется.

Тестирование функции slide

import XCTest
@testable import Game2048ChatGPT_Test

final class Game2048ChatGPT_Tests: XCTestCase {
    var viewModel: GameViewModel!
    
    override func setUpWithError() throws {
 // Put setup code here. This method is called before the invocation of each test method in the class.
        super.setUp()
               viewModel = GameViewModel()
    }
    override func tearDownWithError() throws {
 // Put teardown code here. This method is called after the invocation of each test method in the class.
        viewModel = nil
        super.tearDown()
    }
    func testSlideLeftWithoutMerging() {
            viewModel.tiles = [
                [Tile(value: 2, position: Position(row: 0, col: 0)),
                 Tile(value: 0, position: Position(row: 0, col: 1)), 
                 Tile(value: 4, position: Position(row: 0, col: 2)), 
                 Tile(value: 0, position: Position(row: 0, col: 3))],

                [Tile(value: 0, position: Position(row: 1, col: 0)), 
                 Tile(value: 0, position: Position(row: 1, col: 1)), 
                 Tile(value: 2, position: Position(row: 1, col: 2)), 
                 Tile(value: 0, position: Position(row: 1, col: 3))],

                [Tile(value: 0, position: Position(row: 2, col: 0)),
                 Tile(value: 2, position: Position(row: 2, col: 1)), 
                 Tile(value: 0, position: Position(row: 2, col: 2)), 
                 Tile(value: 4, position: Position(row: 2, col: 3))],

                [Tile(value: 4, position: Position(row: 3, col: 0)),
                 Tile(value: 4, position: Position(row: 3, col: 1)), 
                 Tile(value: 0, position: Position(row: 3, col: 2)), 
                 Tile(value: 2, position: Position(row: 3, col: 3))]
            ]
            
            let (moved, score) = viewModel.slide(.left)
            
            XCTAssertTrue(moved)
            XCTAssertEqual(score, 8)
            XCTAssertEqual(viewModel.tiles[0][0].value, 2)
            XCTAssertEqual(viewModel.tiles[0][1].value, 4)
            XCTAssertEqual(viewModel.tiles[0][2].value, 0)
            XCTAssertEqual(viewModel.tiles[0][3].value, 0)
            XCTAssertEqual(viewModel.tiles[3][0].value, 8)
            XCTAssertEqual(viewModel.tiles[3][1].value, 2)
            XCTAssertEqual(viewModel.tiles[3][2].value, 0)
            XCTAssertEqual(viewModel.tiles[3][3].value, 0)
        }
        
        func testSlideRightWithMerging() {
            viewModel.tiles = [
                [Tile(value: 2, position: Position(row: 0, col: 0)),
                 Tile(value: 2, position: Position(row: 0, col: 1)), 
                 Tile(value: 4, position: Position(row: 0, col: 2)), 
                 Tile(value: 4, position: Position(row: 0, col: 3))],

                [Tile(value: 0, position: Position(row: 1, col: 0)),
                 Tile(value: 2, position: Position(row: 1, col: 1)), 
                 Tile(value: 0, position: Position(row: 1, col: 2)), 
                 Tile(value: 2, position: Position(row: 1, col: 3))],

                [Tile(value: 4, position: Position(row: 2, col: 0)),
                 Tile(value: 4, position: Position(row: 2, col: 1)), 
                 Tile(value: 4, position: Position(row: 2, col: 2)), 
                 Tile(value: 4, position: Position(row: 2, col: 3))],

                [Tile(value: 0, position: Position(row: 3, col: 0)),
                 Tile(value: 0, position: Position(row: 3, col: 1)), 
                 Tile(value: 0, position: Position(row: 3, col: 2)), 
                 Tile(value: 2, position: Position(row: 3, col: 3))]
            ]
            
            let (moved, score) = viewModel.slide(.right)
            viewModel.printBoard()
                       
            XCTAssertTrue(moved)
            XCTAssertEqual(score, 32)
            XCTAssertEqual(viewModel.tiles[0][0].value, 0)
            XCTAssertEqual(viewModel.tiles[0][1].value, 0)
            XCTAssertEqual(viewModel.tiles[0][2].value, 4)
            XCTAssertEqual(viewModel.tiles[0][3].value, 8)
            XCTAssertEqual(viewModel.tiles[1][0].value, 0)
            XCTAssertEqual(viewModel.tiles[1][1].value, 0)
            XCTAssertEqual(viewModel.tiles[1][2].value, 0)
            XCTAssertEqual(viewModel.tiles[1][3].value, 4)
            XCTAssertEqual(viewModel.tiles[2][0].value, 0)
            XCTAssertEqual(viewModel.tiles[2][1].value, 0)
            XCTAssertEqual(viewModel.tiles[2][2].value, 8)
            XCTAssertEqual(viewModel.tiles[2][3].value, 8)
            XCTAssertEqual(viewModel.tiles[3][0].value, 0)
            XCTAssertEqual(viewModel.tiles[3][1].value, 0)
            XCTAssertEqual(viewModel.tiles[3][2].value, 0)
            XCTAssertEqual(viewModel.tiles[3][3].value, 2)
        }
        
        func testSlideUp() {
            viewModel.tiles = [
                [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))],

                [Tile(value: 4, position: Position(row: 1, col: 0)),
                 Tile(value: 4, position: Position(row: 1, col: 1)), 
                 Tile(value: 2, position: Position(row: 1, col: 2)), 
                 Tile(value: 2, position: Position(row: 1, col: 3))],

                [Tile(value: 2, position: Position(row: 2, col: 0)),
                 Tile(value: 2, position: Position(row: 2, col: 1)), 
                 Tile(value: 4, position: Position(row: 2, col: 2)), 
                 Tile(value: 4, position: Position(row: 2, col: 3))],

                [Tile(value: 4, position: Position(row: 3, col: 0)),
                 Tile(value: 0, position: Position(row: 3, col: 1)), 
                 Tile(value: 4, position: Position(row: 3, col: 2)), 
                 Tile(value: 0, position: Position(row: 3, col: 3))]
            ]
            
            let (moved, score) = viewModel.slide(.up)
            
            XCTAssertTrue(moved)
            XCTAssertEqual(score, 8)
            XCTAssertEqual(viewModel.tiles[0][0].value, 2)
            XCTAssertEqual(viewModel.tiles[0][1].value, 2)
            XCTAssertEqual(viewModel.tiles[0][2].value, 2)
            XCTAssertEqual(viewModel.tiles[0][3].value, 4)
            XCTAssertEqual(viewModel.tiles[1][0].value, 4)
            XCTAssertEqual(viewModel.tiles[1][1].value, 4)
            XCTAssertEqual(viewModel.tiles[1][2].value, 8)
            XCTAssertEqual(viewModel.tiles[1][3].value, 2)
            XCTAssertEqual(viewModel.tiles[2][0].value, 2)
            XCTAssertEqual(viewModel.tiles[2][1].value, 2)
            XCTAssertEqual(viewModel.tiles[2][2].value, 0)
            XCTAssertEqual(viewModel.tiles[2][3].value, 4)
            XCTAssertEqual(viewModel.tiles[3][0].value, 4)
            XCTAssertEqual(viewModel.tiles[3][1].value, 0)
            XCTAssertEqual(viewModel.tiles[3][2].value, 0)
            XCTAssertEqual(viewModel.tiles[3][3].value, 0)
        }
        
        func testSlideDown() {
            viewModel.tiles = [
                [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))],

                [Tile(value: 4, position: Position(row: 1, col: 0)),
                 Tile(value: 4, position: Position(row: 1, col: 1)), 
                 Tile(value: 2, position: Position(row: 1, col: 2)),
                 Tile(value: 2, position: Position(row: 1, col: 3))],

                [Tile(value: 2, position: Position(row: 2, col: 0)),
                 Tile(value: 2, position: Position(row: 2, col: 1)),
                 Tile(value: 4, position: Position(row: 2, col: 2)), 
                 Tile(value: 4, position: Position(row: 2, col: 3))],

                [Tile(value: 4, position: Position(row: 3, col: 0)),
                 Tile(value: 0, position: Position(row: 3, col: 1)), 
                 Tile(value: 4, position: Position(row: 3, col: 2)), 
                 Tile(value: 0, position: Position(row: 3, col: 3))]
            ]
            
            let (moved, score) = viewModel.slide(.down)
            
            XCTAssertTrue(moved)
            XCTAssertEqual(score, 8)
            XCTAssertEqual(viewModel.tiles[3][0].value, 4)
            XCTAssertEqual(viewModel.tiles[3][1].value, 2)
            XCTAssertEqual(viewModel.tiles[3][2].value, 8)
            XCTAssertEqual(viewModel.tiles[3][3].value, 4)
            XCTAssertEqual(viewModel.tiles[2][0].value, 2)
            XCTAssertEqual(viewModel.tiles[2][1].value, 4)
            XCTAssertEqual(viewModel.tiles[2][2].value, 2)
            XCTAssertEqual(viewModel.tiles[2][3].value, 2)
            XCTAssertEqual(viewModel.tiles[1][0].value, 4)
            XCTAssertEqual(viewModel.tiles[1][1].value, 2)
            XCTAssertEqual(viewModel.tiles[1][2].value, 0)
            XCTAssertEqual(viewModel.tiles[1][3].value, 4)
            XCTAssertEqual(viewModel.tiles[0][0].value, 2)
            XCTAssertEqual(viewModel.tiles[0][1].value, 0)
            XCTAssertEqual(viewModel.tiles[0][2].value, 0)
            XCTAssertEqual(viewModel.tiles[0][3].value, 0)
        }
}

Все тесты успешно пройдены:

ШАГ 13  Структура AIGame и функция oneStepGame.

Итак, мы поступим проще, мы создадим структуру struct AIGame и добавим туда метод oneStepGame, в котором создадим  экземпляр GameViewModel с заданной игровой доской matrix и выполним slide в заданном направлении: 

func oneStepGame(direction: Direction, matrix: [[Tile]]) ->  (moved: Bool, score: Int)

Этот метод будем использовать для симуляции движения плиток и получения  информации о том, произошло ли какое-то перемещение плиток moved, и насколько увеличился счет score. Давайте попросим это сделать ChatGPT:

struct AIGame {

    // Method to simulate one step of the game in a given direction
    func oneStepGame(direction: Direction, matrix: [[Tile]]) ->
                                                (moved: Bool, score: Int) {
        var gameViewModel = GameViewModel() // Initialize GameViewModel
        gameViewModel.tiles = matrix // Set the tiles to the provided matrix

        // Call the slide method on GameViewModel
        let result = gameViewModel.slide(direction)

        // Return the result from the slide method
        return result
    }
}
let aiGame = AIGame()
let matrix = // your current matrix of tiles
let result = aiGame.oneStepGame(direction: .left, matrix: matrix)

// Accessing the result
let moved = result.moved

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

Мы можем использовать AIGame для определения окончания игры:

   class GameViewModel: ObservableObject {
    @Published var tiles: [[Tile]] = []
    @Published var isGameOver = false
    @Published var score: Int = 0
   
    private var aiGame = AIGame()
//.  .  .  .  .  .  .  .  .  .  .  .  . 
    func move(_ direction: Direction) {
        let (moved, score) = slide(direction)
        
        if moved {
            self.score += score
            addNewTile()
        }
        checkGameOver()
     }

     func checkGameOver() {
            // Logic to check if the game is over
            if !canMove() {
                isGameOver = true
            }
      }
        
    func canMove() -> Bool {
      return Direction.allCases.contains { direction in
               aiGame.oneStepGame(direction: direction, matrix: tiles).moved
           }
     }
  }
struct GameView: View {
 @StateObject var viewModel: GameViewModel
 
 var body: some View { 
       VStack {
            Text("2048")
                .font(.largeTitle)
                .padding()
            
            HStack {
                // Score Display
                Text("Score: \(viewModel.score)")
                .monospacedDigit()
                Spacer()
            }
            .font(.title)
            .foregroundColor(.accentColor)
            .padding()

      // Game Over                
        if viewModel.isGameOver {
                 Text("Game Over")
                  .font(.title) 
                  .foregroundColor(.red) 
          }

         // Your game UI components here (e.g., grid view, score display).
 
      } .padding() 
    } 
 }

Чем плох Text («GameOver»), предложенный ChatGPT:

if viewModel.isGameOver {
                Text("Game Over")
                    .font(.largeTitle)
                    .foregroundColor(.red)
 }

Тем, что это условный UI, и если условие не выполняется, то места на экране под него не будет выделено. И наш UI будет “скакать” по вертикали.

Что предложит  ChatGPT, если его спросить об этом:

import SwiftUI

struct GameView: View {
    @ObservedObject var viewModel: GameViewModel
    
    var body: some View {
        VStack {
            // Your game UI components here...

            Text(viewModel.isGameOver ? "Game Over" : "---- ----")
                .font(.largeTitle)
                .foregroundColor(viewModel.isGameOver ? .red : .clear)
                .frame(maxWidth: .infinity)
                .padding(.top)

            // Other game UI components (e.g., grid view, score display)...
        }
        .padding()
    }
}

Вот мой код:

// Game Over
  Text(viewModel.isGameOver  ? "Game Over": "____ ____")
        .font(.title)
        .foregroundColor(isGameOver  ? .red : .clear)

Всё работает. Вот как функционирует приложение:

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

Возвращаемся к расчету оптимального направления:

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

class GameViewModel: ObservableObject {
    @Published var tiles : [[Tile]] = []
    @Published var isGameOver = false
    @Published var score = 0
    
    private var aiGame = AIGame()
    init() {
        resetGame()
    }
// .  .  .  .  .  .  .  .


func bestMoveDirection() -> Direction {
    var bestDirection: Direction = .left // Default to .left initially
    var maxScore = 0
    
    for direction in Direction.allCases {
        let (moved, score) = aiGame.oneStepGame(direction: direction, matrix: tiles)
        if moved && score > maxScore {
            maxScore = score
            bestDirection = direction
        }
    }  
    return bestDirection
}

Давайте протестируем  bestMoveDirection.

Тестирования bestMoveDirection

import XCTest
@testable import YourGameModule

class GameViewModelTests: XCTestCase {

    func testBestMoveDirection() {
        // Example 1: Test a board where moving right is the best move
        let tiles1: [[Tile]] = [
            [Tile(value: 2, position: Position(row: 0, col: 0)), 
             Tile(value: 2, position: Position(row: 0, col: 1)), 
             Tile(value: 4, position: Position(row: 0, col: 2)), 
             Tile(value: 8, position: Position(row: 0, col: 3))],

            [Tile(value: 0, position: Position(row: 1, col: 0)), 
             Tile(value: 4, position: Position(row: 1, col: 1)), 
             Tile(value: 0, position: Position(row: 1, col: 2)), 
             Tile(value: 4, position: Position(row: 1, col: 3))],

            [Tile(value: 8, position: Position(row: 2, col: 0)), 
             Tile(value: 8, position: Position(row: 2, col: 1)), 
             Tile(value: 8, position: Position(row: 2, col: 2)), 
             Tile(value: 8, position: Position(row: 2, col: 3))],

            [Tile(value: 16, position: Position(row: 3, col: 0)), 
             Tile(value: 0, position: Position(row: 3, col: 1)), 
             Tile(value: 16, position: Position(row: 3, col: 2)), 
             Tile(value: 16, position: Position(row: 3, col: 3))]
        ]
        
        var viewModel = GameViewModel()
        viewModel.tiles = tiles1
        
        let bestDirection1 = viewModel.bestMoveDirection()
        XCTAssertEqual(bestDirection1, .right, 
                       "The best move should be to the right in this case.")
        
        // Example 2: Test a board where moving down is the best move
        let tiles2: [[Tile]] = [
            [Tile(value: 0, position: Position(row: 0, col: 0)), 
             Tile(value: 0, position: Position(row: 0, col: 1)), 
             Tile(value: 4, position: Position(row: 0, col: 2)), 
             Tile(value: 8, position: Position(row: 0, col: 3))],

            [Tile(value: 4, position: Position(row: 1, col: 0)), 
             Tile(value: 4, position: Position(row: 1, col: 1)), 
             Tile(value: 0, position: Position(row: 1, col: 2)), 
             Tile(value: 0, position: Position(row: 1, col: 3))],

            [Tile(value: 2, position: Position(row: 2, col: 0)), 
             Tile(value: 2, position: Position(row: 2, col: 1)), 
             Tile(value: 4, position: Position(row: 2, col: 2)), 
             Tile(value: 8, position: Position(row: 2, col: 3))],

            [Tile(value: 0, position: Position(row: 3, col: 0)), 
             Tile(value: 4, position: Position(row: 3, col: 1)), 
             Tile(value: 0, position: Position(row: 3, col: 2)), 
             Tile(value: 4, position: Position(row: 3, col: 3))]
        ]
        
        viewModel.tiles = tiles2
        
        let bestDirection2 = viewModel.bestMoveDirection()
        XCTAssertEqual(bestDirection2, .down, 
                           "The best move should be down in this case.")
        
        // Example 3: Test a board where moving right is the best move
        let tiles3: [[Tile]] = [
            [Tile(value: 4, position: Position(row: 0, col: 0)), 
             Tile(value: 2, position: Position(row: 0, col: 1)), 
             Tile(value: 2, position: Position(row: 0, col: 2)), 
             Tile(value: 0, position: Position(row: 0, col: 3))],

            [Tile(value: 2, position: Position(row: 1, col: 0)), 
             Tile(value: 2, position: Position(row: 1, col: 1)), 
             Tile(value: 0, position: Position(row: 1, col: 2)), 
             Tile(value: 0, position: Position(row: 1, col: 3))],

            [Tile(value: 2, position: Position(row: 2, col: 0)), 
             Tile(value: 0, position: Position(row: 2, col: 1)), 
             Tile(value: 0, position: Position(row: 2, col: 2)), 
             Tile(value: 0, position: Position(row: 2, col: 3))],

            [Tile(value: 4, position: Position(row: 3, col: 0)), 
             Tile(value: 4, position: Position(row: 3, col: 1)), 
             Tile(value: 4, position: Position(row: 3, col: 2)), 
             Tile(value: 4, position: Position(row: 3, col: 3))]
        ]
        
        viewModel.tiles = tiles3
        
        let bestDirection3 = viewModel.bestMoveDirection()
        XCTAssertEqual(bestDirection3, .right, 
                      "The best move should be to the right in this case.")
    }
}

Благодаря этому тесту удалось уточнить код для  bestMoveDirection():

func bestMoveDirection() -> Direction {
        var bestDirection: Direction = .left
            var maxScore = 0
            
            for direction in Direction.allCases {
                let (moved, score) = aiGame.oneStepGame(direction: direction, 
                                                           matrix: tiles)
                if moved && score >= maxScore {
                    maxScore = score
                    bestDirection = direction
                }
            }
        
        return bestDirection
}

Визуализация оптимального направления bestMoveDirection

import SwiftUI

struct GameView: View {
    @StateObject private var viewModel = GameViewModel()
    @State private var isShowingOptimalDirection = false

    let tileSize: CGFloat = 80
    let padding: CGFloat = 8
    
    var body: some View {
        VStack {
            Text("2048")
                .font(.largeTitle)
                .padding()
            
            HStack {
                // Score Display
                Text("Score: \(viewModel.score)")
                .monospacedDigit()
                Spacer()
            }
            .font(.title)
            .foregroundColor(.accentColor)
            .padding()
            
            //  Grid
            GridView(tiles: viewModel.tiles, tileSize: tileSize, 
                    padding: padding, 
                    optimalDirection: viewModel.bestMoveDirection(), 
                    isShowingOptimalDirection: isShowingOptimalDirection)
                .gesture(
                    DragGesture()
                        .onEnded { value in
                          withAnimation {
                                handleSwipe(value: value)
                          }
                        }
                )
            
            HStack {
                // Reset Button
                Button(action: {
                    withAnimation {
                        viewModel.resetGame()
                    }
                }) {
                    Text("Restart")
                        .font(.title)
                        .padding()
                }
                
                Spacer()
                
                // Show Optimal
                Button(action: {
                    isShowingOptimalDirection.toggle()
                }) {
                    HStack {
                        Image(systemName:
                  isShowingOptimalDirection ? "checkmark.square" : "square")
                            .resizable()
                            .frame(width: 34, height: 34)
                        Text("Hint")
                    }
                }
            }
            .font(.title)
            .foregroundColor(.accentColor)
            .padding()
        }
    }
   
   // Handle swipe gesture and trigger game actions
   private func handleSwipe(value: DragGesture.Value) {. . .}
 }

 // Define the GridView to use TileView
struct GridView: View {
    let tiles: [[Tile]]
    let tileSize : CGFloat
    let padding : CGFloat
    //-------------------
    let optimalDirection: Direction
    var isShowingOptimalDirection: Bool
    
    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)
                       }
                   }
               }
           }
           .padding()
           .background(Color.colorBG)

           // Foreground tiles (only non-zero values)
           ForEach(tiles.flatMap { $0 }.filter { $0.value != 0 }) { tile in
                TileView(tile: tile, tileSize: tileSize, padding: padding)
            }
           .padding()
           if isShowingOptimalDirection {
                   OptimalDirectionArrow(direction: optimalDirection)
                   .padding()
           }
        }
       .frame(width: 4 * tileSize + 3 * padding, 
             height: 4 * tileSize +  3 * padding) // Adjust frame size
    }
}
struct OptimalDirectionArrow: View {
    var direction: Direction
    
    var body: some View {
        GeometryReader { geometry in
          let arrowLength = min(geometry.size.width, geometry.size.height) / 2
            
            Path { path in
                switch direction {
                case .up:
                    path.move(to: CGPoint(x: geometry.size.width / 2, 
                                          y: geometry.size.height / 2))
                    path.addLine(to: CGPoint(x: geometry.size.width / 2, 
                                  y: geometry.size.height / 2 - arrowLength))
                case .down:
                    path.move(to: CGPoint(x: geometry.size.width / 2, 
                                          y: geometry.size.height / 2))
                    path.addLine(to: CGPoint(x: geometry.size.width / 2, 
                                  y: geometry.size.height / 2 + arrowLength))
                case .left:
                    path.move(to: CGPoint(x: geometry.size.width / 2, 
                                          y: geometry.size.height / 2))
                    path.addLine(to: CGPoint(
                               x: geometry.size.width / 2 - arrowLength, 
                              y: geometry.size.height / 2))
                case .right:
                    path.move(to: CGPoint(x: geometry.size.width / 2, 
                                          y: geometry.size.height / 2))
                    path.addLine(to: CGPoint(
                                  x: geometry.size.width / 2 + arrowLength, 
                                  y: geometry.size.height / 2))
                }
            }
            .stroke(Color.red, lineWidth: 4)
            .overlay(
                ArrowheadShape(direction: direction)
                        .fill(Color.red)
                        .frame(width: 20, height: 20)
                        .rotationEffect( rotationAngle(for: direction))
                        .position(arrowheadPosition(in: geometry.size, 
                               direction: direction, length: arrowLength))
                        .transaction { transaction in
                            transaction.animation = nil
                        }
            )
        }
    }
    
    func arrowheadPosition(in size: CGSize, direction: Direction, length: CGFloat) -> CGPoint {
        switch direction {
        case .up:
            return CGPoint(x: size.width / 2, y: size.height / 2 - length)
        case .down:
            return CGPoint(x: size.width / 2, y: size.height / 2 + length)
        case .left:
            return CGPoint(x: size.width / 2 - length, y: size.height / 2)
        case .right:
            return CGPoint(x: size.width / 2 + length, y: size.height / 2)
        }
    }
    
    func rotationAngle(for direction: Direction) -> Angle {
           switch direction {
           case .up:
               return Angle.degrees(0)
           case .down:
               return Angle.degrees(180)
           case .left:
               return Angle.degrees(-90)
           case .right:
               return Angle.degrees(90)
           }
       }
}
struct ArrowheadShape: Shape {
    var direction: Direction
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        let tip = CGPoint(x: rect.midX, y: rect.minY)
        let left = CGPoint(x: rect.minX, y: rect.maxY)
        let right = CGPoint(x: rect.maxX, y: rect.maxY)
        
        path.move(to: tip)
        path.addLine(to: left)
        path.addLine(to: right)
        path.closeSubpath()
        return path
    }
}

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

В следующем посте «iOS приложение игры 2048 в SwiftUI с ChatGPT 4-o. Часть 3. ИИ (AI)» мы рассмотрим ИИ алгоритмы Expectimax и Monte Carlo в игре 2048