Я хочу поделиться с вами опытом создания "с нуля" iOS приложения известной игры 2048 с элементами ИИ (искусственного интеллекта) в SwiftUI с помощью ChatGPT .
В своем классическом варианте, когда играет пользователь с помощью жестов (вверх, вниз, вправо, влево), это довольно простая игра и создать полноценное iOS приложение для такой игры 2048 можно за короткое время, при этом код будет понятен каждому. Но простые правила игры только подталкивают к созданию оптимальных алгоритмов решения игры 2048, то есть к созданию ИИ, который мог бы играть в эту игру автоматически и максимизировать счет игры в разумные сроки.
Мне хотелось написать игру 2048 именно на SwiftUI, пользуясь его прекрасной и мощной анимацией и приличным быстродействием , a также предоставить в распоряжения пользователя не только “ручной” способ игры, когда Вы руководите тем, каким должен быть следующий ход: вверх, вниз, влево и вправо, но и ряд алгоритмов с оптимальной стратегией (метода Монте-Карло, стратегий поиска по деревьям (Minimax, Expectimax) ), позволяющих АВТОМАТИЧЕСКИ выполнять ходы - вверх, вниз, влево и вправо - и добиться плитки с числом 2048 и более (эти алгоритмы и называют алгоритмами “искусственного интеллекта” (ИИ)). Необходимым элементом ИИ является алгоритм поиска, который позволяет смотреть вперед на возможные будущие позиции, прежде чем решить, какой ход он хочет сделать в текущей позиции.
2048 - это очень известная игра, и мне не нужно было объяснять ChatGPT ее правила, он сам всё про неё знает. Кроме того, оказалось, что ChatGPT прекрасно осведомлен об ИИ алгоритмах для игры 2048, так что мне вообще не пришлось описывать ChatGPT контекст решаемой задачи. И он предлагал мне множество таких неординарных решений, которые мне пришлось бы долго выискивать в научных журналах.
Чтобы вы в дальнейшем смогли оценить эти решения, я кратко напомню правила игры 2048.
Сама игра проста. Вам дается игровое поле размером 4×4, где каждая плитка может содержать число внутри себя.
Числа на игровом поле всегда будут степенью двойки. Изначально есть только две плитки с номерами 2 или 4. Вы можете менять игровое поле, нажимая на клавиши со стрелками - вверх, вниз, вправо, влево - и все плитки будут двигаться в этом направлении, пока не будет остановлены либо другой плиткой, либо границей сетки. Если две плитки с одинаковыми числами столкнутся во время движения, они сольются в новую плитку с их суммой. Новая плитка не может повторно слиться с другой соседней плиткой во время этого перемещения. После перемещения новая плитка с числом 2 или 4 случайным образом появится на одной из пустых плиток, после чего игрок делает новый ход.
Цель игры состоит в том, чтобы достичь плитки с числом 2048, но её можно рассматривать более широко и достигать плитку с максимально возможным числом. На самом деле существует система подсчета очков, применяемая к каждому ходу. Счет игрока начинается с нуля и увеличивается всякий раз, когда две плитки объединяются, на значение нового числа объединенной плитки. Если нет пустой ячейки и больше нет допустимых ходов, то игра заканчивается.
Итак, моя задача заключалась не только в том, чтобы создать движок игры 2048 на Swift, но и разработать UI c анимацией движения плиток с помощью SwiftUI, a также задействовать ИИ (алгоритмы Expectimax и Monte Carlo) в игре 2048. При этом я хотела максимально использовать возможности ChatGPT.
В статье подробно рассмотрены следующие этапы разработки такого iOS приложения игры 2048 с помощью ChatGPT:
Логика игры без анимации.
Разработка UI (анимация перемещения плиток и появления новых случайных плиток, отображение оптимального направления перемещения плиток на игровом поле).
Добавление AI (алгоритмы Greedy, Expectimax и MonteCarlo) в игру 2048 c автоматическим запуском.
На третьем этапе я получила от ChatGPT два алгоритма ИИ - Expectimax и Monte Carlo - и их варианты, которые позволяют получать очень приличные результаты - плитки со значениями 4096 и 8092.
Заметьте, какой разный рисунок игры у этих двух совершенно разных ИИ алгоритмов, стремящихся к одному и тому же результату.
Я использовала бесплатную версию 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-х постах на моем сайте:
iOS приложения игры 2048 в SwiftUI с ChatGPT 4-o. Часть 1. Введение. Логика игры 2048.
iOS приложения игры 2048 в SwiftUI с ChatGPT 4-o. Часть 2. Анимация и UI.
iOS приложение игры 2048 в SwiftUI с ChatGPT. Часть 3. ИИ (AI) для игры 2048.
А код находится на GitHub. В этой статье я представляю лишь отдельные фрагменты этого протокола, чтобы вы могли почувствовать, насколько полезным может быть ChatGPT как при разработки, так и при обучении.
Итак, первый запрос к ChatGPT:
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
}
}
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)
}
}
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, которые придется наполнять кодом.
Получим код для самой главной функции move()
с помощью CharGPT:
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. Перемещение и слияние плиток работает, но без анимации, кроме того, нет индикации об окончании игры и счета. Давайте сначала добавим анимацию плиток на игровом поле.
Давайте проведем подготовку к анимации перемещения плиток и наделим наши плитки не только значением 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)
}
}
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
.
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
}
}
Ранее у нас был такой код для 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
}
}
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
}
}
}
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), и это выглядит не совсем хорошо, нам бы хотелось, чтобы появление новых плиток анимировалось “по месту” плиток в игровом поле.
Давайте спросим, как добиться этого у 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) :
Следующие шаги я не буду рассматривать здесь, с ними можно подробно познакомиться в посте :
Результат работы приложения после применения вышеуказанных шагов:
Теперь, когда на любом этапе игры 2048 мы можем определить оптимальное направление перемещения плиток с помощью bestMoveDirection()
, мы можем заменить ручной swipe жест на автоматический запуск перемещение плиток в оптимальном направлении. и тем самым реализовать своего рода "жадный" (greedy) ИИ (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)
}
}
}
}
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:
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
}
// 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
}
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...
}
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
поиск оптимального хода:
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)
}
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: [[Tile]]) -> Double {
return Double(board.flatMap { $0 }.filter { $0.value == 0 }.count)
}
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
}
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
}
Два способа организации игровой доски в виде Snake паттерна показаны на рисунке:
[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
}
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
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
}
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)
}
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.
}
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
}
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)
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
}
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)
}
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
:
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)
}
// 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 получаешь истинное удовольствие.
Что же понравилось больше всего?
ChatGPT сразу предлагает полную архитектуру вашего приложения с “заглушками” для конкретных методов и вычисляемых переменных, но которую вы можете дальше успешно развивать, ссылаясь на эти заглушки без дополнительных разъяснений.
ChatGPT предлагает очень содержательные идентификаторы для переменных var
, констант let
и названий функций func
, что существенно облегчает чтение кода и избавляет вас от того, чтобы “ломать голову” над этим. И вы также можете ссылаться на них в последующем диалоге с ChatGPT.
ChatGPT 4-o в совершенстве владеет функциями высшего порядка для работы с коллекциями (map
, flatMap
, compactMap
, filter
, allSatisfy
) в Swift и всюду предлагает их, иногда в самых неожиданных ситуациях и самым изобретательным образом, что приятно удивляет.
Прекрасно владеет архитектурой MVVM (возможно, и другими, просто не пробовала), предлагая как незащищенную модель, когда ViewModel
и Model
в одном классе (с протоколом ObservableObject
или новым макросом @Observable
), так и классическую защищенную модель: Model
отдельно от ViewModel
и View
. Легко переходит от одной к другой.
Расшифровывает все ошибки и даёт дельные советы по их исправлению.
В большинстве случаев запоминает и хранит наработанный в процессе взаимодействия код для поставленной задачи на протяжении почти всей сессии и позволяет ссылаться на различные его этапы.
Хорошо рефакторит код.
Генерирует Unit тесты с использованием XCTest.
Проявляет фантастическую эрудицию в части ИИ алгоритмов для игр типа 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 приложений.