У меня есть статья, посвященная созданию с помощью ChatGPT 4-o SwiftUI iOS приложения игры 2048: “Азартная разработка iOS приложения игры 2048 с ChatGPT”. Этой статье в октябре 2025 больше года.
И вот год спустя в нашем распоряжении оказывается ChatGPT 5, существенно более мощный ИИ. И хочется проверить на той же самой задаче создания iOS приложения игры 2048 на SwiftUI, насколько более интеллектуальные и интересные решения по сравнению с Chat GPT 4-o предлагает ChatGPT 5.
Начав работать с ChatGPT 5 вы понимаете, что общается он с вами уже как партнер класса Middle или Senior, требуя от вас того же уровня знаний. Он не дает вам подробных объяснений как начинающему iOS программисту в случае ChatGPT 4-o, a дает лишь очень краткие заметки к предлагаемому законченному коду, полагая, что в нем вы должны увидеть все его идеи. ChatGPT 5 разговаривает с вами кодом, и никаких “заглушек”, как в ChatGPT 4-o, только законченные решения. Кроме того, он опирается на более современные версии iOS и Swift.
Чтобы вы в дальнейшем вы смогли оценить те или иные решения, я очень кратко напомню правила игры 2048.
Сама игра проста. Вам дается игровое поле размером 4×4, где каждая плитка может содержать число внутри себя.

Рис.1 Пример хода в 2048. После хода “сдвиг влево” (left) на левой доске. Доска слева станет той, что расположена на рис. справа.
Числа на игровом поле всегда будут степенью двойки. Изначально есть только две плитки с номерами 2 или 4. Вы можете менять игровое поле, нажимая на клавиши со стрелками — вверх, вниз, вправо, влево — и все плитки будут двигаться в этом направлении, пока не будет остановлены либо другой плиткой, либо границей сетки. Если две плитки с одинаковыми числами столкнутся во время движения, они сольются в новую плитку с их суммой. Новая плитка не может повторно слиться с другой соседней плиткой во время этого перемещения. После перемещения новая плитка с числом 2 или 4 случайным образом появится на одной из пустых плиток, после чего игрок делает новый ход.
Цель игры состоит в том, чтобы достичь плитки с числом 2048. Цель игры можно рассматривать более широко и достигать плитку с максимально возможным числом. На самом деле существует система подсчета очков, применяемая к каждому ходу. Счет игрока начинается с нуля и увеличивается всякий раз, когда две плитки объединяются, на значение нового числа объединенной плитки. Если нет пустой ячейки и больше нет допустимых ходов, то игра заканчивается.
Задание для ChatGPT 5 выглядит точно также, как и в случае c ChatGPT 4-o:

Вы профессиональный разработчик iOS. Предложите мне iOS-приложение игры 2048 на SwiftUI с начислением очков и кнопкой перезапуска. |
Код игры 2048, написанный на SwiftUI с помощью ChatGPT 4-o находится на Github, я не буду утомлять вас его описание, a код, написанный с помощью ChatGPT 5 — на Github.
Режимы работы приложения 2048
- Ручной с помощью жестов (.up, down, .left, .right)

- Автоматический ИИ Monte Carlo

- Автоматический ИИ Expectimax

ChatGPT 5 реализует MVVM (Model-View-ViewModel) — шаблон проектирования iOS приложения.
Модель игры 2048
Модель данных игры 2048 представляет собой состояние игры и она очень простая:
- Игровое поле 4 x 4: board
- Счет: score
- Сигнал об окончании игры: isGameOver
Логика игры заключается в управлении этим состоянием с помощью следующих тоже довольно простых функций:
- формирование начального состояния игры 2048: startGame()
- добавление новой плитки на игровое поле на свободное место случайным образом со случайным значением (2 или 4): addRandomTile()
- ход игры 2048, то есть перемещения плиток в указанном направлении direction и слияния плиток Tile с одинаковыми значениями value, a также добавление новой плитки случайным образом и изменение счета score: move (direction: Direction) ()
- определение окончания игры: CheckGameOver()
ChatGPT 5 в отличие от ChatGPT 4-o в качестве модели игрового поля выбрал не интуитивно понятный двумерный массив var tiles: [[Tile]] = [] с пустыми ячейками, a плоский одномерный массив плиток var tiles: [Tile] = [] с ненулевыми значениями value, так как именно они участвуют во всех перемещениях и слияниях.
Поскольку функция move(direction:), имитирующая ход игры 2048, выполняет сразу две вещи:
- Детерминированное перемещение и слияние плиток (чистая логика игры).
- Стохастика — добавление новой плитки + проверка конца игры
ChatGPT 5 предусмотрительно разделил функцию move (direction: Direction) () на две функции:
- slide(direction:) — чистая функция (только движение + слияние).move(direction:) — игровой режим (slide + addRandomTile + checkGameOver).
Функию slide(direction:) хороша тем, что её можно использовать в тестах и в AI алгоритме Expectimax.
Game ChatGPT 5:
import Foundation
// Возможные направления движения
enum Direction : CaseIterable {
case left, right, up, down
}
// Модель плитки
struct Tile: Identifiable, Equatable {
let id = UUID () // уникальный идентификатор
var value: Int // значение плитки (2, 4, 8, ...)
var position: Position // её позиция на поле
var merged: Bool = false // была ли плитка объединена в этом ходе
}
// Позиция плитки на игровом поле (строка и колонка)
struct Position: Equatable {
var row: Int
var col: Int
}
// Основная модель игры
struct Game {
let size = 4 // размер поля (4x4)
var tiles: [Tile] = [] // массив плиток
var score = 0 // текущий счёт
var gameOver = false // флаг окончания игры
init() {
startGame()
}
/// Инициализатор для глубокого копирования состояния (нужен для симуляций AI)
init(copying other: GameViewModel) {
self.tiles = other.tiles
self.score = other.score
self.gameOver = other.gameOver
}
/// Начало новой игры
func startGame() {
gameOver = false
score = 0
tiles.removeAll()
// В начале появляются 2 случайные плитки
addRandomTile()
addRandomTile()
}
/// Добавление новой случайной плитки (2 или 4) в пустую ячейку
func addRandomTile() {
// Находим все пустые позиции
let emptyPositions = (0..<size).flatMap { r in
(0..<size).compactMap { c in
tiles.contains(where:
{ $0.position.row == r && $0.position.col == c }) ?
nil : Position (row:r, col:c)
}
}
// Если нет пустых -- выходим
guard let pos = emptyPositions.randomElement() else { return }
// Новая плитка -- 2 или 4
let newValue = Bool.random() ? 2 : 4
let newTile = Tile(value: newValue, position: pos)
tiles.append(newTile)
}
/// Выполняет сдвиг и объединение плиток в заданном направлении.
/// Возвращает `true`, если хотя бы одна плитка была перемещена или объединена.
func slide(direction: Direction) -> Bool {
var moved = false // флаг, показывающий, изменилось ли поле после хода
var newTiles: [Tile] = [] // новый набор плиток после сдвига
// MARK: - Функция сжатия одной линии (строки или столбца)
/// Сжимает линию (объединяет одинаковые соседние плитки).
/// Возвращает новую линию и флаг, были ли слияния.
func collapse(_ line: [Tile]) -> ([Tile], Bool) {
var newLine: [Tile] = [] // результирующая линия после слияния
var skip = false // пропуск следующей плитки, если она уже объединена
var merged = false // флаг -- происходило ли слияние в этой линии
for (i, tile) in line.enumerated() {
if skip {
// Пропускаем плитку, если предыдущая уже была объединена с ней
skip = false
continue
}
// Проверяем две соседние плитки: если равны -- объединяем
if i < line.count - 1 && tile.value == line[i + 1].value {
var mergedTile = line[i + 1]
mergedTile.value *= 2 // увеличиваем значение в 2 раза
mergedTile.merged = true // помечаем как объединенную
score += mergedTile.value // добавляем очки за слияние
newLine.append(mergedTile) // новую плитку в newLine
skip = true // пропускаем следующую плитку
merged = true // отмечаем, что произошло слияние
} else {
// Если не равны -- просто добавляем плитку как есть
newLine.append(tile)
}
}
return (newLine, merged)
}
// MARK: - Сдвиг плиток в выбранном направлении
switch direction {
case .left:
// Сдвигаем все строки влево
for r in 0..<size {
// Получаем все плитки строки, сортируем по возрастанию колонки
let row = tiles.filter { $0.position.row == r }
.sorted { $0.position.col < $1.position.col }
// Сжимаем строку (объединяем одинаковые плитки)
let (collapsed, merged) = collapse(row)
if merged { moved = true }
// Переставляем плитки в новые позиции слева направо
for (c, tile) in collapsed.enumerated() {
if tile.position.col != c { moved = true }
var updated = tile
updated.position = Position(row: r, col: c)
newTiles.append(updated)
}
}
case .right:
// Сдвигаем все строки вправо
for r in 0..<size {
// Плитки сортируются справа налево
let row = tiles.filter { $0.position.row == r }
.sorted { $0.position.col > $1.position.col }
let (collapsed, merged) = collapse(row)
if merged { moved = true }
// Размещаем объединённые плитки справа налево
for (i, tile) in collapsed.enumerated() {
let c = size - 1 - i
if tile.position.col != c { moved = true }
var updated = tile
updated.position = Position(row: r, col: c)
newTiles.append(updated)
}
}
case .up:
// Сдвигаем все колонки вверх
for c in 0..<size {
let col = tiles.filter { $0.position.col == c }
.sorted { $0.position.row < $1.position.row }
let (collapsed, merged) = collapse(col)
if merged { moved = true }
// Переставляем плитки сверху вниз
for (r, tile) in collapsed.enumerated() {
if tile.position.row != r { moved = true }
var updated = tile
updated.position = Position(row: r, col: c)
newTiles.append(updated)
}
}
case .down:
// Сдвигаем все колонки вниз
for c in 0..<size {
let col = tiles.filter { $0.position.col == c }
.sorted { $0.position.row > $1.position.row }
let (collapsed, merged) = collapse(col)
if merged { moved = true }
// Размещаем объединённые плитки снизу вверх
for (i, tile) in collapsed.enumerated() {
let r = size - 1 - i
if tile.position.row != r { moved = true }
var updated = tile
updated.position = Position(row: r, col: c)
newTiles.append(updated)
}
}
}
// Если было движение -- обновляем состояние поля
if moved {
tiles = newTiles
}
return moved
}
/// Выполняет полный ход: вызывает slide(),
/// а затем добавляет новую плитку и проверяет конец игры.
func move(direction: Direction) {
let changed = slide(direction: direction)
if changed {
addRandomTile() // добавляем новую плитку на свободное место
checkGameOver() // проверяем, есть ли возможные ходы
}
}
/// Проверка, закончена ли игра
private func checkGameOver() {
// Есть ли пустая клетка?
let emptyExists = (0..<size).contains { r in
(0..<size).contains { c in
!tiles.contains {
$0.position.row == r && $0.position.col == c }
}
}
if emptyExists { return }
// Проверяем соседей -- если есть равные, то ещё можно ходить
for r in 0..<size {
for c in 0..<size {
let value = tiles.first {
$0.position.row == r && $0.position.col == c }!.value
let neighbors = [(r+1,c),(r-1,c),(r,c+1),(r,c-1)]
for (nr, nc) in neighbors where
nr >= 0 && nr < size && nc >= 0 && nc < size {
if tiles.first(where: {
$0.position.row == nr && $0.position.col == nc })?.value == value {
return
}
}
}
}
// Если ни пустых плиток, ни возможных объединений нет -- игра окончена
gameOver = true
}
/// Преобразовать плоский массив [Tile] в двумерную сетку [[Int]]
/// - Returns: [[Int]] where 0 means empty cell
func tilesToGrid () -> [[Int]] {
// Создать пустую сетку grid, заполненную нулями
var grid = Array(repeating: Array(repeating: 0, count: size),
count: size)
for tile in tiles {
let r = tile.position.row
let c = tile.position.col
grid[r][c] = tile.value
}
return grid
}
// Возвращает список пустых позиций как [Position]
func emptyPositions() -> [Position]{
// Находим все пустые позиции
let emptyPositions = (0..<size).flatMap { r in
(0..<size).compactMap { c in
tiles.contains(where: {
$0.position.row == r && $0.position.col == c }) ? nil :
Position (row:r, col:c)
}
}
return emptyPositions
}
}
Мы видим, что:
- Плитка Tile моделируется почти точно также, как раньше в ChatGPT 4-o, структурой Tile со свойствами:
- уникальный идентификатор id
- значение плитки value
- позиция position на игровом поле 4 x 4
- единственное отличие в том, что ChatGPT 5 добавил флаг merged, сигнализирующий, что значение value получено слиянием плиток (используется для дополнительной анимации “слияния”)
- позиция position на игровом поле 4 x 4
- значение плитки value
2. Игровое поле моделируется иначе: одномерным массивом плиток var tiles: [Tile] = []
с ненулевыми значениями value
.
3. Счет игры отслеживается в переменной var score = 0.
4. Окончание игры фиксируется в переменной var gameOver = false
Логика игры 2048 реализуется в функциях:
- startGame()
- addRandomTile()
- move (direction: Direction) ()
- CheckGameOver()
Надо сказать, что эти функции в ChatGPT 5 отличаются от аналогичных функций версии ChatGPT 4-o в силу того, что у них разные Модели игрового поля.
ChatGPT 5 использует более простую Модель в виде плоского массива плиток var tiles: [Tile] = [] с ненулевыми значениями value, и можно предположить, что с такой Моделью труднее отрабатывать операции перемещения плиток (вправо, влево, вверх, вниз) на двумерном игровом поле 4 x 4, но — НЕТ.
ChatGPT 5 настолько изобретательно использует функции высшего порядка flatMap, compactMap, filter, contains и т.д. языка программирования Swift, что более лаконичного и читабельного кода трудно себе представить.
Посмотрите, как изящно в функции slide (direction: Direction) () при перемещении плиток влево .left или вправо .right ChatGPT 5 извлекает нужная строка row из плоского массива плиток tiles: [Tile] и сортирует её по столбцу col по убыванию или возрастанию в зависимости от направления перемещения .left или .right:
switch direction {
// Двигаем все строки влево
case .left:
for r in 0..<size {
// Получаем все плитки строки, сортируем по возрастанию колонки
let row = tiles.filter { $0.position.row == r }
.sorted {$0.position.col < $1.position.col }
// Сжимаем строку (объединяем одинаковые плитки)
let (collapsed, merged) = collapse(row)
if merged { moved = true }
// . . . .
}
// Двигаем все строки вправо
case .right:
for r in 0..<size {
// Плитки сортируются справа налево
let row = tiles.filter { $0.position.row == r }
.sorted { $0.position.col > $1.position.col }
// Сжимаем строку (объединяем одинаковые плитки)
let (collapsed, merged) = collapse(row)
if merged { moved = true }
// . . . . .
}
Невероятно, но в одной строке кода мы сразу получаем “сжатую” строку row для перемещения влево .left:
// Получаем все плитки строки, сортируем по возрастанию колонки
let row = tiles.filter { $0.position.row == r }
.sorted {$0.position.col < $1.position.col }
… или инвертированную “сжатую” строку row для перемещения вправо .right:
let row = tiles.filter { $0.position.row == r }
.sorted { $0.position.col > $1.position.col }
Полученная строка row: [Tile] передается универсальной функции collapse (), которая выполняет слияние плиток с одинаковыми значениями value при движении влево:
// MARK: - Функция сжатия одной линии (строки или столбца)
/// Сжимает линию (объединяет одинаковые соседние плитки).
/// Возвращает новую линию и флаг, были ли слияния.
func collapse(_ line: [Tile]) -> ([Tile], Bool) {
var newLine: [Tile] = [] // результирующая линия после слияния
var skip = false // пропуск следующей объединенной плитки
var merged = false // флаг -- происходило ли слияние в этой строке
for (i, tile) in line.enumerated() {
if skip {
// Пропускаем плитку, если предыдущая уже была объединена с ней
skip = false
continue
}
// Проверяем две соседние плитки: если равны -- объединяем
if i < line.count - 1 && tile.value == line[i + 1].value {
var mergedTile = line[i + 1]
mergedTile.value *= 2 // увеличиваем значение в 2 раза
mergedTile.merged = true // помечаем как объединенную
score += mergedTile.value // добавляем очки за слияние
newLine.append(mergedTile) // добавляем новую плитку в строку
skip = true // пропускаем следующую плитку
merged = true // отмечаем, что произошло слияние
} else {
// Если не равны -- просто добавляем плитку как есть
newLine.append(tile)
}
}
return (newLine, merged)
}
После сортировки строки по столбцам выполняется слияние плиток для строки row с помощью функции collapse (row) и результат размещается в новой строке collapsed:
let (collapsed, merged) = collapse(row)
if merged { moved = true }
Далее определяем, было ли движение (moved = true) плиток в строке collapsed в заданном направлении, то есть изменилась ли позиция position.col новой плитки tile по сравнению с плиткой row[c] в строке row:
// Переставляем плитки в новые позиции слева направо
for (c, tile) in collapsed.enumerated() {
if tile.position.col != c { moved = true }
var updated = tile
updated.position = Position(row: r, col: c)
newTiles.append(updated)
}
Это важно для добавления новой случайной плитки на игровое поле в функции move(direction: Direction) :
func move(direction: Direction) {
let changed = slide( direction: direction)
if changed {
addRandomTile()
checkGameOver()
}
}
Для измененной плитки var updated = tile в новой строке collapsed позиция position определяется либо напрямую при перемещении влево .left:
for (c, tile) in collapsed.enumerated() {
if tile.position.col != c { moved = true }
var updated = tile
updated.position = Position(row: r, col: c)
newTiles.append(updated)
}
… либо, используя инверсный индекс с для столбца при перемещении вправо .right:
for (i, tile) in collapsed.enumerated() {
let c = size - 1 - i
if tile.position.col != c { moved = true }
var updated = tile
updated.position = Position(row: r, col: c)
newTiles.append(updated)
}
Для направлений .up и .down работаем аналогичным образом, но не со строкой row, a со столбцом col:
case .up:
for c in 0..<size {
let col = tiles.filter { $0.position.col == c }
.sorted { $0.position.row < $1.position.row }
let (collapsed, merged) = collapse(col)
if merged { moved = true }
for (r, tile) in collapsed.enumerated() {
if tile.position.row != r { moved = true }
var updated = tile
updated.position = Position(row: r, col: c)
newTiles.append(updated)
}
}
case .down:
for c in 0..<size {
let col = tiles.filter { $0.position.col == c }
.sorted { $0.position.row > $1.position.row }
let (collapsed, merged) = collapse(col)
if merged { moved = true }
for (i, tile) in collapsed.enumerated() {
let r = size - 1 - i
if tile.position.row != r { moved = true }
var updated = tile
updated.position = Position(row: r, col: c)
newTiles.append(updated)
}
}
Итак, ChatGPT 5 существенно продвинулся интеллектуально при создании View Model для игры 2048. Количество кода минимально и он очень хорошо читается. Но самое главное этот код очень быстродействующий, так как использует встроенные в Swift функции высшего порядка.
БРАВО!!
Всем, кто изучает язык программирования Swift очень рекомендую изучить этот код для практики работы с функциями высшего порядка. И посмотреть не только функцию slide (direction: Direction), но и addRandomTile() и CheckGameOver().
Конечно, с ChatGPT 5 все было не так гладко, как представлено в конечном варианте кода, потребовались мелкие уточнения для корректировки, но в целом ChatGPT 5 прекрасно справился с задачей и идея представить игровое поле в виде плоского одномерного массива непустых плиток var tiles: [Tile] = [] реально оказалась очень хороша.
View Model
View Model ChatGPT 5 и ChatGPT 4-o1 практически не отличаются. За исключением того, что ChatGPT 5 предлагает вариант полностью готовый для Swift 6 (устраняет гонку данных).
Этот дизайн:
- Использует макрос @Observable (современный реактивный компонент SwiftUI)
- Сохраняет Game как чистую структуру struct (Sendable)
- Выполняет логику ИИ в structed concurrency (Task, withTaskGroup)
- Потокобезопасен (нет нарушений Sendable)
GameViewModel ChatGPT 5:
import SwiftUI
@Observable @MainActor
class GameViewModel {
// var tiles: [Tile] = [] // массив плиток
// var score = 0 // текущий счёт
// var gameOver = false // флаг окончания игры
private var game = Game ()
let expectimax = Expectimax()
init() {
self.game = Game()
}
var tiles: [Tile] {
game.tiles
}
var score: Int {
game.score
}
var gameOver: Bool {
game.gameOver
}
var optimalDirection: Direction? {
MonteCarlo.bestDirectionSync(tiles: tiles)
//expectimax.bestDirectionExpectimax(game: game)
}
// MARK: Intents
func startGame() {
game.startGame()
}
func move(direction: Direction) {
game.move(direction: direction)
}
func mergedTileFalse (tile: Tile) {
// Переустановка флага merged только после анимацииo
if let index = game.tiles.firstIndex(where: { $0.id == tile.id }) {
game.tiles[index].merged = false
}
}
/// Вариант для автопилота Монте Карло асинхронный вызов)
func playAITurn2() {
Task {
if let dir = await MonteCarlo.bestDirection(tiles: tiles) {
// await MainActor.run {
game.move(direction: dir)
// }
}
}
}
/// Вариант для автопилота Expectimax (асинхронный вызов)
func playAIExpectimaxAsync () {
Task {
if let dir = await expectimax.bestDirectionExpectimaxAsync (game: game) {
// await MainActor.run {
game.move(direction: dir)
// }
} else {
game.checkGameOver()
}
}
}
}
GameViewModel владеет экземпляром Game и предоставляет действия необходимые для UI.
Разработка UI
ChatGPT 5 предлагает UI, который практически не отличается от UI, созданного ChatGPT 4-o, так как несмотря на двухмерную модель игрового поля var tiles: [[Tile]] = [] с пустыми ячейками, использует плоский массив tiles.flatMap { $0 }.filter { $0.value != 0 } для построения UI, как и ChatGPT 5. Исключение составляет то, что к анимации перемещения плиток и анимации появления новой плитки, ChatGPT 5 добавляет “пульсирующую” анимацию слияния плиток с одинаковыми значениями value, которая технически очень интересно реализована в GameView и TileView.
GameView:
struct GameView: View {
@State var viewModel = GameViewModel()
let gridSize: CGFloat = 80 // tile size
let spacing: CGFloat = 8
var boardSize: CGFloat {
4 * gridSize + 3 * spacing
}
var body: some View {
VStack {
Text("Score: \(viewModel.score)")
.font(.title)
.padding()
if viewModel.gameOver {
Text("Game is Over")
.font(.largeTitle)
.foregroundColor(.red)
.padding()
}
ZStack {
// Grid background (gray cells)
VStack(spacing: spacing) {
ForEach(0..<4) { _ in
HStack(spacing: spacing) {
ForEach(0..<4) { _ in
RoundedRectangle(cornerRadius: spacing)
.fill(Color.gray.opacity(0.15))
.frame(width: gridSize, height: gridSize)
}
}
}
}
// Animated tiles
ForEach(viewModel.tiles) { tile in
TileView(tile: tile, size: gridSize, spacing: spacing){
// Переустановка флага merged сразу после анимацииo
viewModel.mergedTileFalse (tile: tile)
}
}
} // ZStack
.padding()
.frame(width: boardSize, height: boardSize)// 👈 fixed frame
.gesture(
DragGesture()
.onEnded { value in
let horizontal = value.translation.width
let vertical = value.translation.height
if abs(horizontal) > abs(vertical) {
if horizontal > 0 {
withAnimation {
viewModel.move(direction: .right)
}
} else {
withAnimation{
viewModel.move(direction: .left)
}
}
} else {
if vertical > 0 {
withAnimation {
viewModel.move(direction: .down)
}
} else {
withAnimation{
viewModel.move(direction: .up)
}
}
}
}
)
Button("Restart") {
viewModel.startGame()
}
}
.padding()
} // VStack
} // body
}
#Preview {
GameView()
}
TileView:
import SwiftUI
struct TileView: View {
let tile: Tile
let size: CGFloat
let spacing: CGFloat
@State private var isPopping = false
var onPopFinished: () -> Void // callback для сброса состояния после объединения плитки
var body: some View {
// 📍 Вычисляем позицию плитки на поле
let tilePosition =
CGPoint(
x: CGFloat(tile.position.col) * (size + spacing) + size / 2,
y: CGFloat(tile.position.row) * (size + spacing) + size / 2
)
RoundedRectangle(cornerRadius: 8)
// 🎨 Цвет плитки в зависимости от значения
.fill(color(for: tile.value))
.frame(width: size, height: size)
// 📝 Накладываем текст с числом плитки
.overlay(
Text(tile.value, format:
.number.precision(.fractionLength(0)).grouping(.never))
.font(.title)
.bold()
// Цвет текста: светлый для маленьких чисел, белый для больших
.foregroundColor(tile.value > 4 ? .white : .black)
)
// 🔄 Анимация "прыжка" при объединении плиток
.scaleEffect(isPopping ? 1.2 : 1.0)
.onChange(of: tile.merged) { _, merged in
if merged {
// Запускаем анимацию увеличения плитки
withAnimation {
isPopping = true
}
completion: {
// После завершения возвращаем размер обратно
withAnimation {
isPopping = false
onPopFinished()
}
}
}
}
// 📍 Устанавливаем позицию плитки на поле
.position(tilePosition)
// Анимация плавного перемещения плитки
.animation(.easeInOut(duration: 0.4), value: tile.position)
// 🎭 Анимация появления и удаления плитки
.transition(
.asymmetric(
insertion: .scale(scale: 0.12)
.combined(
with: .offset(
x: tilePosition.x - 2 * size,
y: tilePosition.y - 2 * size
)
),
removal: .opacity
)
)
}
// 🎨 Определение цвета плитки по её значению
private func color(for value: Int) -> Color {. . .}
}
#Preview {
// 🔍 Превью плитки со значением 8 в позиции (0,2)
TileView(tile: Tile(value: 8, position: Position(row: 0, col: 2)),
size: 70, spacing: 8) { }
}
В реализации UI ChatGPT 5 добавлен фрагмент кода, который анимирует «слияния» объединяющихся плиток путем изменения масштаба плитки (как в настоящей игре 2048):
struct TileView: View {
let tile: Tile
let size: CGFloat
let spacing: CGFloat
@State private var isPopping = false
var onPopFinished: () -> Void // callback для сброса состояния после объединения плитки
// . . . . . . . . . . . . . . . .
// 🔄 Анимация "прыжка" при объединении плиток
.scaleEffect(isPopping ? 1.2 : 1.0)
.onChange(of: tile.merged) { _, merged in
if merged {
// Запускаем анимацию увеличения плитки
withAnimation {
isPopping = true
}
completion: {
// После завершения возвращаем размер обратно
withAnimation {
isPopping = false
onPopFinished()
}
}
}
}
// . . . . . . . . . . . . . . . . .
}

Для этого в TileView используется withAnimation c параметром completion, эта версия withAnimation работает начиная с iOS 17:
struct TileView: View {
let tile: Tile
let size: CGFloat
let spacing: CGFloat
@State private var isPopping = false
var onPopFinished: () -> Void // callback to reset merged
// . . . . . . . .
.scaleEffect(isPopping ? 1.2 : 1.0) // 👈 pop animation
.onChange(of: tile.merged) { _, merged in
if merged {
withAnimation {
isPopping = true
}
completion: {
withAnimation {
isPopping = false
onPopFinished ()
}
}
}
}
// . . . . . . .
}
Если произошло “слияние” плиток (merged = true), выполняется анимация увеличения масштаба плитки. По окончании анимации масштаб этих плиток возвращается к прежнему нормальному масштабу благодаря выполнению в GameView замыкания onPopFinished(), в котором плитке возвращается начальное значение merged = false.
фрагмент GameView:
struct GameView: View {
@State var viewModel = GameViewModel()
// . . . . . .
// Animated tiles
ForEach(viewModel.tiles) { tile in
TileView(tile: tile, size: gridSize, spacing: spacing) {
// Переустановка флага merged сразу после анимацииo
viewModel.mergedTileFalse (tile: tile)
}
}
// . . . . . .
}
Теперь у нас есть анимация всех изменений плиток на игровом поле:
- явная анимация их перемещения:
struct TileView: View {
let tile: Tile
let size: CGFloat
let spacing: CGFloat
// . . . . . . . .
let tilePosition =
CGPoint (x: CGFloat(tile.position.col) * (size + spacing) + size / 2,
y: CGFloat(tile.position.row) * (size + spacing) + size / 2)
// . . . . . .
.position( tilePosition)
.animation(.easeInOut(duration: 0.4), value: tile.position)
// . . . . . . .
}
- анимация добавления новых плиток с помощью асимметричной transition:
.transition (
.asymmetric(
insertion: .scale(scale: 0.12)
.combined(
with: .offset(
x: tilePosition.x - 2 * size,
y: tilePosition.y - 2 * size
)
),
removal: .opacity
)
)
асимметричность .asymmetric гарантирует, что SwiftUI применяет разные переходы для появления и исчезновения плиток: для появления insertion — плитка масштабируется (.scale) от 0.12, а также смещается (.offset), при исчезновении removal — просто исчезает (.opacity).
- неявная анимация “слияния” плиток с одинаковыми значениями value с помощью withAnimation:
@State private var isPopping = false
var onPopFinished: () -> Void // callback to reset merged
// . . . . . . . .
.scaleEffect(isPopping ? 1.2 : 1.0) // 👈 pop animation
.onChange(of: tile.merged) { _, merged in
if merged {
withAnimation {
isPopping = true
}
completion: {
withAnimation {
isPopping = false
onPopFinished ()
}
}
}
}
Вот как выглядит это в замедленном темпе:

Оптимальное направление перемещения плиток в игре 2048
Также я попросила ChatGPT 5 рассчитать оптимальное направление перемещения плиток на основе метода Монте Карло и отобразить его на игровой доске для удобства выполнения ручного управления игрой 2048 с помощью жестов.
Расчет
Сначала ChatGPT 5 предложит синхронную версию расчета оптимального направление перемещения плиток методом Монте Карло:
// MARK: - Monte Carlo AI
/// Synchronous Monte Carlo (single-threaded, blocking)
func bestDirectionSync (simulations: Int = 100, depth: Int = 15) -> Direction? {
var bestDirection: Direction?
var bestScore = Int.min
for direction in Direction.allCases {
// 🔍 Check if the move is valid
let testCopy = GameViewModel(copying: self)
let beforeTiles = testCopy.tiles
testCopy.move(direction: direction)
if testCopy.tiles == beforeTiles {
continue // ❌ Skip direction that doesn't change the board
}
var totalScore: [Int] = []
for _ in 0..<simulations {
let simulation = GameViewModel(copying: self)
simulation.move(direction: direction)
for _ in 0..<depth {
if simulation.gameOver { break }
if let randomDir = Direction.allCases.randomElement() {
simulation.move(direction: randomDir)
}
}
totalScore.append (simulation.score)
}
let avgScore = totalScore.average()
if avgScore > bestScore {
bestScore = avgScore
bestDirection = direction
}
}
return bestDirection
}
UI оптимального направления и проблемы
И простейшее ArrowView для стрелки, указывающей это оптимальное направление:
struct ArrowView: Shape {
let direction: Direction // Направление стрелки (вверх, вниз, влево, вправо)
func path(in rect: CGRect) -> Path {
var path = Path()
let midX = rect.midX // середина прямоугольника по X
let midY = rect.midY // середина прямоугольника по Y
switch direction {
case .up:
// Вертикальная линия вверх
path.move(to: CGPoint(x: midX, y: rect.minY))
path.addLine(to: CGPoint(x: midX, y: rect.maxY))
// Левая часть стрелки
path.move(to: CGPoint(x: midX, y: rect.minY))
path.addLine(to: CGPoint(x: midX - 20, y: rect.minY + 30))
// Правая часть стрелки
path.move(to: CGPoint(x: midX, y: rect.minY))
path.addLine(to: CGPoint(x: midX + 20, y: rect.minY + 30))
case .down:
// Вертикальная линия вниз
path.move(to: CGPoint(x: midX, y: rect.maxY))
path.addLine(to: CGPoint(x: midX, y: rect.minY))
// Левая часть стрелки
path.move(to: CGPoint(x: midX, y: rect.maxY))
path.addLine(to: CGPoint(x: midX - 20, y: rect.maxY - 30))
// Правая часть стрелки
path.move(to: CGPoint(x: midX, y: rect.maxY))
path.addLine(to: CGPoint(x: midX + 20, y: rect.maxY - 30))
case .left:
// Горизонтальная линия влево
path.move(to: CGPoint(x: rect.minX, y: midY))
path.addLine(to: CGPoint(x: rect.maxX, y: midY))
// Верхняя часть стрелки
path.move(to: CGPoint(x: rect.minX, y: midY))
path.addLine(to: CGPoint(x: rect.minX + 30, y: midY - 20))
// Нижняя часть стрелки
path.move(to: CGPoint(x: rect.minX, y: midY))
path.addLine(to: CGPoint(x: rect.minX + 30, y: midY + 20))
case .right:
// Горизонтальная линия вправо
path.move(to: CGPoint(x: rect.maxX, y: midY))
path.addLine(to: CGPoint(x: rect.minX, y: midY))
// Верхняя часть стрелки
path.move(to: CGPoint(x: rect.maxX, y: midY))
path.addLine(to: CGPoint(x: rect.maxX - 30, y: midY - 20))
// Нижняя часть стрелки
path.move(to: CGPoint(x: rect.maxX, y: midY))
path.addLine(to: CGPoint(x: rect.maxX - 30, y: midY + 20))
}
return path
}
}
… которое он разместил в ZStack для игрового поля:
ZStack {
// Grid background (gray cells)
VStack(spacing: spacing) {
ForEach(0..<4) { _ in
HStack(spacing: spacing) {
ForEach(0..<4) { _ in
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.15))
.frame(width: gridSize, height: gridSize)
}
}
}
}
// Animated tiles
ForEach(viewModel.tiles) { tile in
TileView(tile: tile, size: gridSize, spacing: spacing) {
// Переустановка флага merged сразу после анимацииo
viewModel.mergedTileFalse (tile: tile)
}
}
// ✅ AI Arrow in the center
if let aiDir = viewModel.bestDirectionSync() {
ArrowView(direction: aiDir)
.stroke(Color.blue, style:
StrokeStyle(lineWidth: 3,lineCap:.round,lineJoin: .round))
.frame(width: 120, height: 120)
.animation(.easeInOut, value: aiDir)
}
}
.frame(width: boardSize, height: boardSize) // 👈 fixed frame
Теперь мы можем показать на экране рассчитанное оптимальное направление движения плиток для жеста (.left, .right, .up, .down).
Здесь правда есть одна проблема.
Иногда, после выполнения жеста на игровом поле сначала показывается с помощью стрелки одно оптимальное направление Direction, например, .up, a затем спустя очень короткое время — другое, например, .right, и стрелка “перескакивает” с одного направления на другое. Но так происходит не всегда.

Если рассматривать вывод на консоль, то каждый жест, задающий направление Direction перемещения плиток в игре 2048, сопровождается появлением одного, a иногда пары направлений Direction. При этом, если появляется пара направлений Direction и они совпадают, то на экране отображается только одно направление, a если пара несовпадающих направлений Direction — то сначала одно, a затем — второе. Нас это не устраивает, и мы должны понять, почему иногда вычисляется пара направлений Direction, и как избежать повторного вычисление оптимального направления Direction с помощью метода Монте Карло, так это довольно затратная процедура.
Выяснилось, что это связано с двукратной перерисовкой GameView, которую вызывает наша новая анимацией “слияния” плиток.
“Слияния” (merged = true) запускает в TileView увеличение масштаба (1.2) плитки с анимацией, a по окончании этой анимации в GameView масштаб (1.0) возвращается в нормальное состояние (merged = false) благодаря замыканию onPopFinished():
фрагмент GameView:
struct GameView: View {
@State var viewModel = GameViewModel()
// . . . . . .
// Animated tiles
ForEach(viewModel.tiles) { tile in
TileView(tile: tile, size: gridSize, spacing: spacing) {
// Переустановка флага merged сразу после анимацииo
viewModel.mergedTileFalse (tile: tile)
}
}
// . . . . . .
}
Так что “да”, GameView после жеста DragGesture() может перерисовываться дважды, если есть “слияния” плиток. При этом расчет оптимального направления с помощью Монте Карло viewModel.optimalDirection:
@Observable @MainActor
class GameViewModel {
private var game = Game ()
// . . . . . . . .
var optimalDirection: Direction? {
MonteCarlo.bestDirectionSync(tiles: tiles)
}
// . . . . . . . .
}
… также выполняется дважды, и каждый раз могут быть разные оптимальные направления Direction в силу статистического характера метода Монте Карло.
Как нам избежать этого?
Вместо того, чтобы выполнять вычисления оптимального направления внутри body:
if showHintDirection, let aiDir = viewModel.optimalDirection {
ArrowView(direction: aiDir)
.stroke(Color.blue,
style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
.frame(width: 120, height: 120)
.animation(.easeInOut, value: aiDir)
}
ChatGPT 5 предлагает кэшировать результат в @State переменную var cachedHint и выполнять их один раз по мере необходимости:
struct GameView: View {
@State var viewModel = GameViewModel()
@State private var cachedHint: Direction?
// . . . . . .
// обновляем, если checkbox переключается
.onChange(of: showHintDirection) { oldValue, newValue in
if newValue {
cachedHint = viewModel.optimalDirection
} else {
cachedHint = nil
}
}
… или при изменении viewModel.tiles:
.onChange(of: viewModel.tiles) { oldValue, newValue in
let oldValues = oldValue.map {$0.value}
let newValues = newValue.map {$0.value}
let oldPositions = oldValue.map {$0.position}
let newPositions = newValue.map {$0.position}
if showHintDirection {
if (oldValues != newValues || oldPositions != newPositions) {
let hint = viewModel.optimalDirection
print("hint = \(String(describing: hint))")
withAnimation {
cachedHint = hint
}
}
}
}
Причем при определении оптимального направления Direction нас интересует лишь изменение значений value плиток и их позиций position на игровом поле, изменение свойства merged, которое используется для анимации “слияния”, нас не интересует. Следовательно, при использовании .onChange и при выполнении жеста на игровом поле вызов метода Монте Карло viewModel.optimalDirection будет происходить однократно.
Использование переменной var cachedHint. в GameView выглядит следующим образом:
if showHintDirection, let dir = cachedHint {
ArrowView(direction: dir)
.stroke(Color.blue, style:
StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
.frame(width: 120, height: 120)
.animation(.easeInOut, value: dir)
}
Теперь все работает корректно. Мы видим, что расчет оптимального направления производится однократно:

Анимация оптимального направления
Мы бы хотели анимировать изменение оптимального направления на экране. Но сейчас ArrowView — это простая геометрическая фигура Shape в виде стрелки, а SwiftUI анимирует изменения геометрической фигуры Shape только при изменении animatableData.
Но направление Direction — это перечисление enum, которое не анимируется. Поэтому явная анимация .animation(.easeInOut) для dir не будет работать, так как не может интерполироваться.
ChatGPT 5 предлагает сделать так, чтобы стрелка действительно плавно изменялась, анимируя угол поворота как число и гарантируя, что изменения будут происходить по кратчайшему пути поворота (так, .left → .up поворачивается на −90°, а не на +270°).
Для этого:
- Создаём геометрическую фигуру Shape, которую можно анимировать с помощью угла angle: Double (в радианах).
- Оборачиваем её в небольшой объект ArrowMorphView, содержащий угол @State private var angle: Double, инициализированный текущим направлением direction.
- При изменении направления вычисляется ближайший эквивалентный угол target (при необходимости прибавляется /вычитается 2π) и анимируется @State угол angle с помощью withAnimation.
- Геометрическая фигура Shape использует cos/sin (с явными преобразованиями Double → CGFloat) для отрисовки древка и наконечника стрелки, обеспечивая плавную трансформацию / вращение.
Код ArrowMorphView
import SwiftUI
// MARK: - ArrowShape (анимируемая форма стрелки по углу в радианах)
struct ArrowShape: Shape {
/// Угол в радианах.
/// 0 = вправо, +90° = вниз, -90° = вверх, π = влево (переводим в вызывающем коде)
var angle: Double
// Свойство для анимации: SwiftUI будет плавно изменять это значение
var animatableData: Double {
get { angle }
set { angle = newValue }
}
func path(in rect: CGRect) -> Path {
var p = Path()
let midX = rect.midX // центр по X
let midY = rect.midY // центр по Y
// Длина линии стрелки и длина "головки", зависящие от размера rect
let length = min(rect.width, rect.height) * 0.32
let headLen = min(rect.width, rect.height) * 0.20
// Вектор направления (угол -> dx, dy)
let dx = CGFloat(cos(angle))
let dy = CGFloat(sin(angle))
// Точка начала линии и кончик стрелки
let start = CGPoint(x: midX - dx * length, y: midY - dy * length)
let tip = CGPoint(x: midX + dx * length, y: midY + dy * length)
// Основная линия стрелки
p.move(to: start)
p.addLine(to: tip)
// Вычисляем два угла для "усиков" стрелки (±135° относительно направления)
let perpAngle1 = angle + (3.0 * .pi / 4.0)
let perpAngle2 = angle - (3.0 * .pi / 4.0)
// Левая и правая точки головки стрелки
let headPoint1 = CGPoint(
x: tip.x + CGFloat(cos(perpAngle1)) * headLen,
y: tip.y + CGFloat(sin(perpAngle1)) * headLen
)
let headPoint2 = CGPoint(
x: tip.x + CGFloat(cos(perpAngle2)) * headLen,
y: tip.y + CGFloat(sin(perpAngle2)) * headLen
)
// Соединяем кончик стрелки с головкой (две линии)
p.move(to: tip)
p.addLine(to: headPoint1)
p.move(to: tip)
p.addLine(to: headPoint2)
return p
}
}
// MARK: - ArrowMorphView (контейнер, анимирует поворот стрелки между направлениями)
struct ArrowMorphView: View {
var direction: Direction // текущее направление
var strokeStyle: StrokeStyle =
StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round) // стиль линии
var color: Color = .blue // цвет стрелки
// Текущий угол (в радианах), хранится в @State для анимации
@State private var angle: Double
init(direction: Direction, strokeStyle:
StrokeStyle = StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round),
color: Color = .blue) {
self.direction = direction
self.strokeStyle = strokeStyle
self.color = color
// Инициализация угла в зависимости от переданного направления
_angle = State(initialValue: Self.directionToAngle(direction))
}
var body: some View {
ArrowShape(angle: angle) // сама форма стрелки
.stroke(color, style: strokeStyle) // применяем цвет и стиль линии
.onChange(of: direction) { oldDir, newDir in
// Целевой угол для нового направления
var target = Self.directionToAngle(newDir)
// Корректируем угол так, чтобы поворот был по кратчайшему пути
while target - angle > .pi { target -= 2 * .pi }
while target - angle < -(.pi) { target += 2 * .pi }
// Анимация поворота
withAnimation(.easeInOut(duration: 0.36)) {
angle = target
}
}
.onAppear {
// Устанавливаем угол в соответствии с начальным направлением (без резкого прыжка)
let initial = Self.directionToAngle(direction)
angle = initial
}
}
/// Преобразуем Direction в угол (радианы):
/// up -> -π/2
/// right -> 0
/// down -> π/2
/// left -> π (то же, что -π)
static func directionToAngle(_ d: Direction) -> Double {
switch d {
case .up: return -Double.pi / 2.0
case .right: return 0.0
case .down: return Double.pi / 2.0
case .left: return Double.pi // то же самое, что -π
}
}
}
Кроме того, ChatGPT предлагает специальный ArrowMorphPreview для предварительного просмотра с целью визуального тестирования ArrowMorphView:
import SwiftUI
struct ArrowMorphPreview: View {
@State private var currentDirection: Direction = .up
var body: some View {
VStack(spacing: 40) {
// Сама стрелка
ArrowMorphView(direction: currentDirection,
strokeStyle: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round),
color: .blue)
.frame(width: 120, height: 120)
.opacity(0.8)
// Кнопки для выбора направления
VStack(spacing: 16) {
Button("⬆️ Вверх") { currentDirection = .up }
HStack {
Button("⬅️ Влево" ) { currentDirection = .left }
Button("➡️ Вправо") { currentDirection = .right }
}
Button("⬇️ Вниз") { currentDirection = .down }
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
#Preview {
ArrowMorphPreview()
}

Используем ArrowMorphView в GameView :
// ✅ AI Arrow in the center
if showHintDirection, let dir = cachedHint, !aiEnabled {
ArrowMorphView(direction: dir)
.frame(width: 125, height: 125)
}
В результате получаем корректное поведение с анимацией стрелки, указывающей оптимальное направление:

Если с СhatGPT 4-o мне пришлось повозится с получением стрелки оптимального направления даже без анимации, то ChatGPT 5 без малейших усилий с моей стороны предоставил мне прекрасное отображение оптимального направления в виде стрелки с анимацией.
Асинхронный метод Монте Карло и ИИ (AI) игра 2048
В методе Монте Карло каждая симуляция независима, поэтому мы можем распределить их между несколькими параллельными задачами с помощью Swift Concurrency TaskGroup, что и делает ChatGPT 5, добиваясь максимальной эффективности по времени :
struct MonteCarlo {
/// Runs Monte Carlo simulations in parallel and returns the best move
func bestDirection(simulations: Int = 100, depth: Int = 15) async -> Direction? {
var bestDirection: Direction?
var bestScore = Int.min
for direction in Direction.allCases {
var totalScore : [Int] = []
// 🔍 Check if the move is valid
let testCopy = GameViewModel(copying: self)
let beforeTiles = testCopy.tiles
testCopy.move(direction: direction)
if testCopy.tiles == beforeTiles {
continue // ❌ Skip direction that doesn't change the board
}
await withTaskGroup(of: Int.self) { simGroup in
for _ in 0..<simulations {
simGroup.addTask {
let simulation = GameViewModel(copying: self)
simulation.move(direction: direction)
for _ in 0..<depth {
if simulation.gameOver { break }
if let randomDir =
Direction.allCases.randomElement() {
simulation.move(direction: randomDir)
}
}
return simulation.score
}
}
for await score in simGroup {
totalScore.append (score)
}
}
let avgScore = totalScore.average()
if avgScore > bestScore {
bestScore = avgScore
bestDirection = direction
}
}
return bestDirection
}
}
extension Array where Element == Int {
func average () -> Int{
let sum = self.reduce(0,+)
return Int(Double(sum) / Double(self.count))
}
}
Ключевые моменты
- withTaskGroup используется только внутри цикла симуляций simulations.
- Каждая симуляция simulation выполняется как отдельная задача task → параллелизм осуществляется на уровне симуляций simulations.
- Внешний цикл по направлениям Direction.allCases последовательный, поэтому мы всегда проверяем направления в контролируемом порядке.
Этот async метод bestDirection(simulations: Int = 100, depth: Int = 15) async -> Direction? можно использовать для расчета оптимального направления Direction? на любом этапе игры 2048 и заменить ручной swipe жест на автоматический запуск перемещение плиток в оптимальном направлении:
@Observable @MainActor
class GameViewModel {
private var game = Game ()
// . . . . . . . .
func playAITurn2() {
Task {
if let dir = await MonteCarlo.bestDirection() {
await MainActor.run {
self.move(direction: dir)
}
}
}
}
// . . . . . . . .
}
… и тем самым реализовать своего рода автоматический запуск AI в игре 2048 c помощью таймера timer = Timer.publish(every: 0.6, on: .main, in: .common).autoconnect(), который размещается в GameView.
С каждым отсчетом таймера timer должен обновляться UI, для этого используется View модификатор .onReceive(timer) { }:
import SwiftUI
struct GameView: View {
@State var viewModel = GameViewModel()
@State private var showHint Direction = false
@State private var aiEnabled = false
@State private var cachedHint: Direction?
@State private var timer =
Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
//. . . . . . . . .
.onReceive(timer) { _ in
// auto-play
if aiEnabled && !viewModel.gameOver {
withAnimation {
viewModel.playAITurn2()
}
} else if viewModel.gameOver {
aiEnabled = false
showHintDirection = false
}
}
} // body
}
Вот как работает ИИ Монте Карло игры 2048.
ИИ игра 2048 может запросто достичь плитки со значением 2048:

Алгоритм Expectimax для ИИ игры 2048
ChatGPT 5 предлагает практически такие же алгоритмы, что и ChatGPT 4-o, но формулирует все очень четко и предоставляет код без каких-либо заглушек.
Вот ответ ChatGPT 5.
Expectimax — отличный выбор для 2048: он явно моделирует игрока (Max) и случайное появление плиток (Chance), а также использует эвристику в конечных узлах.
Ниже я привожу автономное Swift расширение extension для существующей GameViewModel, которое:
- Реализует bestDirectionExpectimax(deep:) -> Direction? (решение верхнего уровня).
- Реализует рекурсивный expectimax с двумя типами узлов: Max (игрок) и Chance (случайное появление плитки).
- Использует эвристическую оценку evaluate (), сочетающую: количество пустых плитки, монотонность, гладкость и максимальное значение плитки value.
- Пропускает невозможные ходы для повышения эффективности.
- Использует GameViewModel (tiles:) (инициализатор копирования игрового поля) для имитации ходов без изменения реальной игры.
Вы можете настроить глубину deep для скорости и качества (глубина deep 3–5 является разумной отправной точкой).
ChatGPT предлагает два варианта кода для алгоритма Expectimax: синхронный и асинхронный.
Cинхронный Expectimax.
Вот как всё это связано:
- bestDirectionExpectimax() → вызывается из UI (для автовоспроизведения или ИИ подсказки оптимального направления).
- slide(direction:) → ход, выполняемый только ИИ (без случайного выбора плитки).
- expectimax(…) → рекурсия, чередующаяся между узлами Max (игрок) и Chance (случайное появление плитки).
- node.evaluate() → эвристическая функция подсчёта очков.
struct Expectimax
/// Выбор оптимального направления с помощью Expectimax
func bestDirectionExpectimax() -> Direction? {
let depth = 4
var bestDir: Direction?
var bestScore = -Double.infinity
let emptyCells = emptyPositions().count
checkGameOver()
if gameOver { return nil }
for dir in Direction.allCases {
let state = GameViewModel(tiles: self.tiles)
let moved = state.slide(direction: dir)
if !moved { continue }
let score = expectimax(node: state,
depth: emptyCells >= 4 ? (depth - 1) : depth, isChanceNode: true)
if score > bestScore {
bestScore = score
bestDir = dir
}
}
return bestDir
}
/// Expectimax recursive function.
/// - Parameters:
/// - node: копия состояния игры
/// - depth: оставшаяся глубина поиска
/// - isChanceNode: если true → узел случая (появление плитки 2/4),
/// если false → ход игрока (max)
/// - Returns: эвристическая оценка (чем больше, тем лучше для игрока)
func expectimax(node: GameViewModel, depth: Int, isChanceNode: Bool) -> Double {
// терминальные условия
checkGameOver()
if depth <= 0 || node.gameOver {
return evaluateBoard(node)
}
if isChanceNode {
// Узел "случай": появляются плитки 2 или 4
let empties = node.emptyPositions()
if empties.isEmpty {
return expectimax(node: node, depth: depth - 1, isChanceNode: false)
}
let p2 = 0.9
let p4 = 0.1
var expected: Double = 0.0
for pos in empties {
// вариант со спавном 2
let spawn2 = GameViewModel(tiles: node.tiles)
let tile2 = Tile(value: 2, position: Position(row: pos.0, col: pos.1))
spawn2.tiles.append(tile2)
expected += p2 * (1.0 / Double(empties.count)) *
expectimax(node: spawn2, depth: depth - 1, isChanceNode: false)
// вариант со спавном 4
let spawn4 = GameViewModel(tiles: node.tiles)
let tile4 = Tile(value: 4, position: Position(row: pos.0, col: pos.1))
spawn4.tiles.append(tile4)
expected += p4 * (1.0 / Double(empties.count)) *
expectimax(node: spawn4, depth: depth - 1, isChanceNode: false)
}
return expected
} else {
// Узел "игрок": выбираем лучший ход (max)
var best = -Double.infinity
for dir in Direction.allCases {
let copy = GameViewModel(tiles: node.tiles)
let moved = copy.slide(direction: dir) // 👈 используем slideMerge
if !moved { continue } // если ход невозможен, пропускаем
let val = expectimax(node: copy, depth: depth - 1, isChanceNode: true)
if val > best { best = val }
}
return best
}
}
// MARK: - Эвристическая оценка
///Эвристическая оценка игровой доски.
/// Объедините факторы (пустые клетки, монотонность, гладкость, максимальное значение плитки).
private func evaluateBoard(_ state: GameViewModel )-> Double {
let grid = tilesToGrid(state.tiles)
let empties = state.emptyPositions().count
// Веса (настраиваемые)
let wEmpty = 11.7
let wMonotonicity = 1.0
let wSmoothness = 0.0 //-0.1
let wMaxWeight = 1.0
let wScore = 0.0
let mono = monotonicity(grid)
let smooth = smoothness(state)
let currentScore = Double(state.score)
let maxTile = Double(state.tiles.map { $0.value }.max() ?? 0)
return wEmpty * Double(empties)
+ wMonotonicity * mono
+ wSmoothness * smooth
+ wMaxWeight * maxTile
+ wScore * currentScore
}
}
Асинхронный Expectimax.
Ветвь Chance (случайное появление плитки) — узкое место в Exceptimax для 2048, поскольку каждая пустая ячейка порождает 2 варианта (плитку 2 и 4). Это быстро растёт.
В Swift это можно распараллелить с помощью withTaskGroup, поскольку каждая дочерняя ветвь независима.
Вот как можно адаптировать узел Chance :
struct Expectimax
//. . . . . .
/// Выбор оптимального направления с помощью Expectimax ( асинхронный )
func bestDirectionExpectimaxAsync() async -> Direction? {
let depth = 5
var bestDir: Direction?
var bestScore = -Double.infinity
let emptyCells = emptyPositions().count
checkGameOver()
if gameOver { return nil }
await withTaskGroup(of: (Direction, Double)?.self) { group in
for dir in Direction.allCases {
group.addTask { [self] in
let state = GameViewModel(tiles: self.tiles)
let moved = state.slide(direction: dir)
if !moved { return nil }
let score = await expectimaxAsync(node: state,
depth: emptyCells >= 4 ? (depth - 1) : depth,
isChanceNode: true)
return (dir, score)
}
}
// Соберите результаты и найдите лучшее направление
for await result in group {
if let (dir, score) = result, score > bestScore {
bestScore = score
bestDir = dir
}
}
}
return bestDir
}
/// Expectimax recursive function.
/// - Parameters:
/// - node: копия состояния игры
/// - depth: оставшаяся глубина поиска
/// - isChanceNode: если true → узел случая (появление плитки 2/4),
/// если false → ход игрока (max)
/// - Returns: эвристическая оценка (чем больше, тем лучше для игрока)
func expectimaxAsync(node: GameViewModel, depth: Int, isChanceNode: Bool) async -> Double {
// терминальные условия
node.checkGameOver()
if depth <= 0 || node.gameOver{
return evaluateBoard(node)
}
if isChanceNode {
// Узел "случай": появляются плитки 2 или 4
let empties = node.emptyPositions()
if empties.isEmpty {
return await expectimaxAsync(node: node,
depth: depth - 1, isChanceNode: false)
}
let p2 = 0.9
let p4 = 0.1
let probPerEmpty = 1.0 / Double(empties.count)
// Run all spawns in parallel
return await withTaskGroup(of: Double.self) { group in
for pos in empties {
group.addTask {
// вариант со значением 2
let spawn2 = GameViewModel(tiles: node.tiles)
let tile2 = Tile(value: 2, position:
Position(row: pos.0, col: pos.1))
spawn2.tiles.append(tile2)
let val2 = await self.expectimaxAsync(node: spawn2,
depth: depth - 1,isChanceNode: false)
return p2 * probPerEmpty * val2
}
group.addTask {
// вариант со значением 4
let spawn4 = GameViewModel(tiles: node.tiles)
let tile4 = Tile(value: 4, position:
Position(row: pos.0, col: pos.1))
spawn4.tiles.append(tile4)
let val4 = await self.expectimaxAsync(node: spawn4,
depth: depth - 1, isChanceNode: false)
return p4 * probPerEmpty * val4
}
}
// Собираем результаты
var total: Double = 0.0
for await partial in group {
total += partial
}
return total
}
} else {
// Узел "игрок": выбираем лучший ход (max)
var best = -Double.infinity
return await withTaskGroup(of: Double.self) { group in
for dir in Direction.allCases {
group.addTask { [self] in
let copy = GameViewModel(tiles: node.tiles)
let moved = copy.slide(direction: dir) // 👈 используем slideMerge
if !moved { return -Double.infinity/} // если ход невозможен, пропускаем
return await expectimaxAsync(node: copy, depth:
depth - 1, isChanceNode: true)
}
}
for await result in group {
best = max( best , result)
}
return best
}
}
}
/// Вариант для автопилота (асинхронный вызов)
func playAIExpectimaxAsync () {
Task {
if let dir = await bestDirectionExpectimaxAsync () {
await MainActor.run {
self.move(direction: dir)
}
} else {
checkGameOver()
}
}
}
}
Теперь каждая ветвь случайных узлов (создание 2 и 4 во всех пустых ячейках) выполняется одновременно.
На устройствах Mac или iOS M1/M2/M3 это обеспечивает значительный прирост производительности, поскольку кооперативная структурированная параллельность Swift аккуратно распределяется по ядрам процессора.

Заключение
- ChatGPT 5 несомненно более продвинутый в интеллектуальном плане и использует более современные версии iOS и Swift.
- Он более лаконичный, ограничиваясь минимальным описанием кода, полагая, что сам по себе код говорит опытному программисту больше любых слов.
- ChatGPT 5 с первого обращения выдает безошибочный код без каких-либо заглушек и с изобретательными синтаксическими конструкциями
- ChatGPT 5 предлагает использовать structured concurrency Swift, так как на устройствах Mac или iOS M1/M2/M3 с современными процессорами Apple Silicon это обеспечивает значительный прирост производительности алгоритмов Monte Carlo и Expectimax, поскольку аккуратно распределяется по ядрам процессора отдельные задачи.
- Добавляет комментарии на любом языке.
Ссылки:
“Азартная разработка iOS приложения игры 2048 с ChatGPT”
“iOS приложения игры 2048 в SwiftUI с ChatGPT 4-o. Часть 1. Введение. Логика игры 2048”
“iOS приложения игры 2048 в SwiftUI с ChatGPT 4-o. Часть 2. Анимация и UI”
“iOS приложение игры 2048 в SwiftUI с ChatGPT. Часть 3. ИИ (AI) для игры 2048”
“Статистика созданных ChatGPT алгоритмов Expeсtimax и Monte Carlo для игры 2048”