Азартная разработка iOS приложения игры 2048 с ChatGPT

от автора

Я хочу поделиться с вами опытом создания «с нуля» 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 приложений.


ссылка на оригинал статьи https://habr.com/ru/articles/851904/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *