Я хочу поделиться с вами опытом создания «с нуля» iOS приложения известной игры 2048 с элементами ИИ (искусственного интеллекта) в SwiftUI с помощью ChatGPT . Код находится на Github.
В своем классическом варианте, когда играет пользователь с помощью жестов (вверх, вниз, вправо, влево), это довольно простая игра и создать полноценное iOS приложение для такой игры 2048 можно за короткое время, при этом код будет понятен каждому. Но простые правила игры только подталкивают к созданию оптимальных алгоритмов решения игры 2048, то есть к созданию ИИ, который мог бы играть в эту игру автоматически и максимизировать счет игры в разумные сроки.
Мне хотелось написать игру 2048 именно на SwiftUI, пользуясь его прекрасной и мощной анимацией и приличным быстродействием , a также предоставить в распоряжения пользователя не только “ручной” способ игры, когда Вы руководите тем, каким должен быть следующий ход: вверх, вниз, влево и вправо, но и ряд алгоритмов с оптимальной стратегией (метода Монте-Карло, стратегий поиска по деревьям (Minimax, Expectimax) ), позволяющих АВТОМАТИЧЕСКИ выполнять ходы — вверх, вниз, влево и вправо — и добиться плитки с числом 2048 и более (эти алгоритмы и называют алгоритмами “искусственного интеллекта” (ИИ)). Необходимым элементом ИИ является алгоритм поиска, который позволяет смотреть вперед на возможные будущие позиции, прежде чем решить, какой ход он хочет сделать в текущей позиции.
2048 — это очень известная игра, и мне не нужно было объяснять ChatGPT ее правила, он сам всё про неё знает. Кроме того, оказалось, что ChatGPT прекрасно осведомлен об ИИ алгоритмах для игры 2048, так что мне вообще не пришлось описывать ChatGPT контекст решаемой задачи. И он предлагал мне множество таких неординарных решений, которые мне пришлось бы долго выискивать в научных журналах.
Чтобы вы в дальнейшем смогли оценить эти решения, я кратко напомню правила игры 2048.
Сама игра проста. Вам дается игровое поле размером 4×4, где каждая плитка может содержать число внутри себя.
Рис.1 Пример хода в 2048. После хода “сдвиг влево” (left) на левой доске. Доска слева станет той, что расположена на рис. справа.
Числа на игровом поле всегда будут степенью двойки. Изначально есть только две плитки с номерами 2 или 4. Вы можете менять игровое поле, нажимая на клавиши со стрелками — вверх, вниз, вправо, влево — и все плитки будут двигаться в этом направлении, пока не будет остановлены либо другой плиткой, либо границей сетки. Если две плитки с одинаковыми числами столкнутся во время движения, они сольются в новую плитку с их суммой. Новая плитка не может повторно слиться с другой соседней плиткой во время этого перемещения. После перемещения новая плитка с числом 2 или 4 случайным образом появится на одной из пустых плиток, после чего игрок делает новый ход.
Цель игры состоит в том, чтобы достичь плитки с числом 2048. Цель игры можно рассматривать более широко и достигать плитку с максимально возможным числом. На самом деле существует система подсчета очков, применяемая к каждому ходу. Счет игрока начинается с нуля и увеличивается всякий раз, когда две плитки объединяются, на значение нового числа объединенной плитки. Если нет пустой ячейки и больше нет допустимых ходов, то игра заканчивается.
Итак, моя задача заключалась не только в том, чтобы создать движок игры 2048 на Swift, но и UI c анимацией движения плиток с помощью SwiftUI, a также задействовать ИИ (алгоритмы Expectimax и Monte Carlo) в игре 2048. При этом я хотела максимально использовать возможности ChatGPT.
Итак, мы подробно рассмотрим в статье следующие этапы разработки такого iOS приложения игры 2048 с помощью ChatGPT:
- Логика игры без анимации.
- Разработка UI (анимация перемещения плиток и появления новых случайных плиток, отображение оптимального направления перемещения плиток на игровом поле).
- Добавление AI (алгоритмы Greedy, Expectimax и MonteCarlo) в игру 2048 c автоматическим запуском.
На первом этапе получим такое iOS приложение игры 2048 без анимации и без счета, a на втором этапе — iOS приложение с анимацией и счетом:
Рис.2 Слева — первый этап разработки iOS приложения игры 2048 (только движок без анимации), справа — второй этап разработки iOS приложения игры 2048 (анимация при ручном способе игры с помощью жестов — вверх, вниз, влево и вправо).
На третьем этапе мы получим два алгоритма ИИ — Expectimax и Monte Carlo — которые позволят нам получить очень приличные результаты — плитки со значениями 4096 и 8092.
Рис.3 Слева — алгоритм Expectimax в действии, справа — наилучший результат алгоритма Expectimax.
Рис.4 Слева — алгоритм Monte Carlo в действии, справа — наилучший результат алгоритма Monte Carlo.
Заметьте, какой разный рисунок игры у этих двух алгоритмов.
Я использовала бесплатную версию ChatGPT 4-o.mini (не самую мощную). Все вопросы задавались на английском и ответы от ChatGPT 4-o.mini тоже были получены на английском, но впоследствие переведены самим же ChatGPT для вас на русский.
Логика игры 2048
Итак, первый запрос к ChatGPT:
ШАГ 1. Начало
import SwiftUI
enum Direction {
case up, down, left, right
}
class GameViewModel: ObservableObject {
@Published var board: [[Int]]
@Published var gameOver = false
init() {
board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
addNewTile()
addNewTile()
}
func addNewTile() {
var emptyTiles: [(Int, Int)] = []
for row in 0..<4 {
for col in 0..<4 {
if board[row][col] == 0 {
emptyTiles.append((row, col))
}
}
}
if let randomTile = emptyTiles.randomElement() {
board[randomTile.0][randomTile.1] = Bool.random() ? 2 : 4
}
}
func resetGame(_ direction: Direction) {
// Reset the board to the initial state
}
func move(_ direction: Direction) {
// Implement the movement logic here
// This will include merging tiles and updating the board state
// After moving tiles, call addNewTile() to add a new tile
}
func checkGameOver() {
// Implement the logic to check if the game is over
// This will involve checking if there are any valid moves left
}
}
struct GameView: View {
@StateObject private var viewModel = GameViewModel()
var body: some View {
VStack {
Text("2048")
.font(.largeTitle)
.padding()
GridView(board: viewModel.board)
.padding()
Button(action: {
// Implement restart functionality
viewModel = viewModel.resetGame()
}) {
Text("Restart")
.font(.title)
.padding()
}
}
.onAppear {
// Initialize game state
}
.onReceive(NotificationCenter.default.publisher(for:
UIResponder.keyboardWillShowNotification)) { _ in
// Handle keyboard events or gestures here
}
}
}
struct GridView: View {
let board: [[Int]]
var body: some View {
VStack(spacing: 8) {
ForEach(0..<4) { row in
HStack(spacing: 8) {
ForEach(0..<4) { col in
TileView(value: board[row][col])
}
}
}
}
}
}
struct TileView: View {
let value: Int
var body: some View {
Text(value == 0 ? "" : "\(value)")
.frame(width: 80, height: 80)
.background(Color.gray.opacity(0.4))
.cornerRadius(10)
.font(.title)
.foregroundColor(.black)
}
}
struct GameView: View {
@StateObject private var viewModel = GameViewModel()
var body: some View {
VStack {
Text("2048")
.font(.largeTitle)
.padding()
GridView(board: viewModel.board)
.padding()
.gesture(
DragGesture()
.onEnded { value in
let horizontalAmount = value.translation.width
let verticalAmount = value.translation.height
if abs(horizontalAmount) > abs(verticalAmount) {
if horizontalAmount > 0 {
viewModel.move(.right)
} else {
viewModel.move(.left)
}
} else {
if verticalAmount > 0 {
viewModel.move(.down)
} else {
viewModel.move(.up)
}
}
}
)
Button(action: {
viewModel = viewModel.resetGame()
}) {
Text("Restart")
.font(.title)
.padding()
}
}
}
}
Копируем полученный код в наш проект в Xcode:
Мы получили вполне себе работающий код генерации UI с необходимыми “заглушками” для логики игры 2048, которые придется наполнять кодом.
Начнем с метода resetGame ()
в классе GameViewModel
, который будет осуществлять перезапуск игры 2048. Опять обращаемся к ChatGPT:
ШАГ 2. Функция resetGame()
import SwiftUI
enum Direction {
case up, down, left, right
}
class GameViewModel: ObservableObject {
@Published var board: [[Int]]
@Published var gameOver = false
init() {
board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
addNewTile()
addNewTile()
}
func addNewTile() {
var emptyTiles: [(Int, Int)] = []
for row in 0..<4 {
for col in 0..<4 {
if board[row][col] == 0 {
emptyTiles.append((row, col))
}
}
}
if let randomTile = emptyTiles.randomElement() {
board[randomTile.0][randomTile.1] = Bool.random() ? 2 : 4
}
}
func move(_ direction: Direction) {
// Implement the movement logic here
// This will include merging tiles and updating the board state
// After moving tiles, call addNewTile() to add a new tile
}
func checkGameOver() {
// Implement the logic to check if the game is over
// This will involve checking if there are any valid moves left
}
func resetGame() {
// Reset the board to the initial state
board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
gameOver = false
addNewTile()
addNewTile()
}
}
struct Gameiew: View {
@StateObject private var viewModel = GameViewModel()
var body: some View {
VStack {
Text("2048")
.font(.largeTitle)
.padding()
GridView(board: viewModel.board)
.padding()
.gesture(
DragGesture()
.onEnded { value in
let horizontalAmount = value.translation.width
let verticalAmount = value.translation.height
if abs(horizontalAmount) > abs(verticalAmount) {
if horizontalAmount > 0 {
viewModel.move(.right)
} else {
viewModel.move(.left)
}
} else {
if verticalAmount > 0 {
viewModel.move(.down)
} else {
viewModel.move(.up)
}
}
}
)
Button(action: {
viewModel.resetGame()
}) {
Text("Restart")
.font(.title2)
.padding()
}
}
}
}
В результате в коде ошибок нет, мы можем кликать на кнопке «Restart» и получать каждый раз новые начальные плитки:
ШАГ 3. Функция move()
Получим код для самой главной функции 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
}
enum Direction {
case up, down, left, right
}
class GameViewModel: ObservableObject {
@Published var board: [[Int]]
@Published var isGameOver = false
init() {
board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
addNewTile()
addNewTile()
}
func addNewTile() {
var emptyTiles: [(Int, Int)] = []
for row in 0..<4 {
for col in 0..<4 {
if board[row][col] == 0 {
emptyTiles.append((row, col))
}
}
}
if let randomTile = emptyTiles.randomElement() {
board[randomTile.0][randomTile.1] = Bool.random() ? 2 : 4
}
}
func move(_ direction: Direction) {
var moved = false
switch direction {
case .up:
rotateLeft()
case .down:
rotateRight()
case .right:
rotateRight()
rotateRight()
case .left:
break
}
for i in 0..<4 {
let compressedRow = compressRow(board[i])
let mergedRow = mergeRow(compressedRow)
let finalRow = compressRow(mergedRow)
if finalRow != board[i] {
moved = true
}
board[i] = finalRow
}
switch direction {
case .up:
rotateRight()
case .down:
rotateLeft()
case .right:
rotateRight()
rotateRight()
case .left:
break
}
if moved {
addNewTile()
checkGameOver()
}
}
func checkGameOver() {
// Implement the logic to check if the game is over
// This will involve checking if there are any valid moves left
}
func resetGame() {
board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
gameOver = false
addNewTile()
addNewTile()
}
private func rotateLeft() {
board = (0..<4).map { i in
(0..<4).map { j in
board[j][3 - i]
}
}
}
private func rotateRight() {
board = (0..<4).map { i in
(0..<4).map { j in
board[3 - j][i]
}
}
}
private func compressRow(_ row: [Int]) -> [Int] {
let filteredRow = row.filter { $0 != 0 }
let zeros = Array(repeating: 0, count: 4 - filteredRow.count)
return filteredRow + zeros
}
private func mergeRow(_ row: [Int]) -> [Int] {
var newRow = row
for i in 0..<3 {
if newRow[i] != 0 && newRow[i] == newRow[i + 1] {
newRow[i] *= 2
newRow[i + 1] = 0
}
}
return newRow
}
}
Круто!!
За 3 шага мы получили работающий вариант игры 2048. Перемещение и слияние плиток работает, но без анимации, кроме того, нет индикации об окончании игры и счета. Давайте сначала добавим анимацию плиток на игровом поле.
ШАГ 4. Подготовка к анимации
Давайте проведем подготовку к анимации перемещения плиток, которые определим с помощью структуры struct Tile
, в которой в качестве одного из свойств плитки используем структуру struct Position { var row: int var col: Int)
для позиционирования плитки Tile
, a также сделаем нашей моделью игрового поля двумерный массив var board: [[Tile]]
:
import SwiftUI
struct Position {
var row: Int
var col: Int
}
struct Tile {
var value: Int
var position: Position
}
import SwiftUI
enum Direction {
case up, down, left, right
}
class GameViewModel: ObservableObject {
@Published var tiles: [[Tile]] = []
@Published var isGameOver = false
init() {
resetGame()
}
func resetGame() {
isGameOver = false
tiles = (0..<4).map { row in
(0..<4).map { col in
Tile(value: 0, position: Position(row: row, col: col))
}
}
addNewTile()
addNewTile()
}
func addNewTile() {
var emptyPositions: [Position] = []
for row in 0..<4 {
for col in 0..<4 {
if tiles[row][col].value == 0 {
emptyPositions.append(Position(row: row, col: col))
}
}
}
if let randomPosition = emptyPositions.randomElement() {
let newValue = Bool.random() ? 2 : 4
tiles[randomPosition.row][randomPosition.col].value = newValue
}
}
func move(_ direction: Direction) {
var moved = false
// Rotate the board so we can always handle the move as a "left" move
switch direction {
case .up:
rotateLeft()
case .down:
rotateRight()
case .right:
rotateRight()
rotateRight()
case .left:
break
}
for i in 0..<4 {
let row = getRow(i)
let compressedRow = compressRow(row)
let mergedRow = mergeRow(compressedRow)
if mergedRow != row {
moved = true
updateRow(i, with: mergedRow)
}
}
switch direction {
case .up:
rotateRight()
case .down:
rotateLeft()
case .right:
rotateRight()
rotateRight()
case .left:
break
}
if moved {
addNewTile()
checkGameOver()
}
}
func checkGameOver() {
// Implement the logic to check if the game is over
}
private func rotateLeft() {
tiles = (0..<4).map { col in
(0..<4).map { row in
var tile = tiles[row][3 - col]
tile.position = Position(row: col, col: row)
return tile
}
}
}
private func rotateRight() {
tiles = (0..<4).map { col in
(0..<4).map { row in
var tile = tiles[3 - row][col]
tile.position = Position(row: col, col: row)
return tile
}
}
}
private func getRow(_ index: Int) -> [Tile] {
return tiles[index]
}
private func updateRow(_ index: Int, with newRow: [Tile]) {
for col in 0..<4 {
tiles[index][col] = newRow[col]
}
}
private func compressRow(_ row: [Tile]) -> [Tile] {
let nonZeroTiles = row.filter { $0.value != 0 }
// Guard to check if we need to compress
guard !nonZeroTiles.isEmpty, nonZeroTiles.count != 4,
!(nonZeroTiles.count == 1 && nonZeroTiles[0].position.col == 0)
else {
// If the row is already in a compressed state, return it as is
return row
}
// Create new row with non-zero tiles and update their positions
let newRow: [Tile] = nonZeroTiles.enumerated().map { (index, tile) in
var updatedTile = tile
updatedTile.position =
Position(row: tile.position.row, col: index)
return updatedTile
}
// Add zeros to the end of the row with updated positions
let zeros = (newRow.count..<row.count).map { colIndex in
Tile(value: 0, position:
Position(row: row[0].position.row, col: colIndex))
}
return newRow + zeros
}
private func mergeRow(_ row: [Tile]) -> [Tile] {
var newRow = row
let nonZeroTiles = row.filter { $0.value != 0 }
// If the row has less than 2 tiles return it as is
guard nonZeroTiles.count > 1 else {
return row
}
for i in 0..<row.count - 1 {
if newRow[i].value != 0 && newRow[i].value == newRow[i + 1].value {
// Merge tiles
newRow[i].value *= 2
// New zero tile on i + ! position
newRow[i + 1] = Tile(value: 0, position:
Position(row: newRow[i].position.row, col: i + 1))
}
}
// Compress the row after merging
return compressRow(newRow)
}
}
struct GameView: View {
@StateObject private var viewModel = GameViewModel()
var body: some View {
VStack {
Text("2048")
.font(.largeTitle)
.padding()
GridView(tiles: viewModel.tiles)
.padding()
.gesture(
DragGesture()
.onEnded { value in
let horizontalAmount = value.translation.width
let verticalAmount = value.translation.height
if abs(horizontalAmount) > abs(verticalAmount) {
if horizontalAmount > 0 {
viewModel.move(.right)
} else {
viewModel.move(.left)
}
} else {
if verticalAmount > 0 {
viewModel.move(.down)
} else {
viewModel.move(.up)
}
}
}
)
Button(action: {
viewModel.resetGame()
}) {
Text("Restart")
.font(.title)
.padding()
}
}
}
}
struct GridView: View {
let tiles: [[Tile]]
var body: some View {
VStack(spacing: 8) {
ForEach(0..<4) { row in
HStack(spacing: 8) {
ForEach(0..<4) { col in
TileView(tile: tiles[row][col])
}
}
}
}
}
}
struct TileView: View {
let tile: Tile
var body: some View {
Text(tile.value == 0 ? "" : "\(tile.value)")
.frame(width: 80, height: 80)
.background(Color.gray.opacity(0.4))
.cornerRadius(10)
.font(.title)
.foregroundColor(.black)
}
}
Используем код в нашем проекте и в GameViewModel
получаем ошибку:
Спрашиваем ChatGPT, как её исправить:
struct Position {
var row: Int
var col: Int
}
struct Tile: Equatable {
var value: Int
var position: Position
}
if finalRow != row {
moved = true
updateRow(i, with: finalRow)
}
Но мы получили ещё одну ошибку:
Спрашиваем ChatGPT, как её исправить:
struct Tile: Equatable {
var value: Int
var position: Position
// Manually implement Equatable conformance
/* static func == (lhs: Tile, rhs: Tile) -> Bool {
return lhs.value == rhs.value &&
lhs.position == rhs.position
}*/
}
struct Position: Equatable {
var row: Int
var col: Int
}
Мы использовали соответствие структуры Position
протоколу Equatable
, которое выполняется Swift автоматически, и ручную реализацию, используя только свойство value
:
import SwiftUI
struct Position: Equatable {
var row: Int
var col: Int
}
struct Tile: Equatable {
// Manually
static func == (lhs: Tile, rhs: Tile) -> Bool {
return lhs.value == rhs.value
}
var value: Int
var position: Position
}
Все работает как и прежде без анимации, но с новой структурой Tile
:
Однако для анимации нам нужно работать с изображением плитки TileView
на игровой доске, и первое, что нам нужно сделать, — это добавить позиционирование плитки TileView
на игровой доске с помощью модификатора .position
, используя свойство position
самой модели Tile
.
ШАГ 5. Модификатор .position
для TileView
import SwiftUI
// Define the TileView
struct TileView: View {
let tile: Tile
var body: some View {
Text(tile.value == 0 ? "" : "\(tile.value)")
.frame(width: 80, height: 80)
.background( Color.gray.opacity(0.4))
.cornerRadius(10)
.font(.title)
.foregroundColor(.black)
.position(getTilePosition())
}
private func getTilePosition() -> CGPoint {
let tileSize: CGFloat = 80 // Adjust based on tile size and padding
let spacing: CGFloat = 10 // Space between tiles
let x =
CGFloat(tile.position.col) * (tileSize + spacing) + tileSize / 2
let y =
CGFloat(tile.position.row) * (tileSize + spacing) + tileSize / 2
return CGPoint(x: x, y: y)
}
}
// Define the GridView to use TileView
struct GridView: View {
let tiles: [[Tile]]
var body: some View {
ZStack {
ForEach(tiles.flatMap { $0 }, id: \.position) { tile in
TileView(tile: tile)
}
}
.frame(width: 4 * 80 + 3 * 10, height: 4 * 80 + 3 * 10) // Adjust frame size
}
}
ШАГ 6. Протокол Identifiable
для ForEach
Ранее у нас был такой код для GridView
:
struct GridView: View {
let tiles: [[Tile]]
var body: some View {
VStack(spacing: 8) {
ForEach(0..<4) { row in
HStack(spacing: 8) {
ForEach(0..<4) { col in
TileView(value:tiles [row][col])
}
}
}
}
}
}
Теперь мы получили новый код GridView
:
// Define the GridView to use TileView
struct GridView: View {
let tiles: [[Tile]]
var body: some View {
ZStack {
ForEach(tiles.flatMap { $0 }, id: \.position) { tile in
TileView(tile: tile)
}
}
.frame(width: 4 * 80 + 3 * 8, height: 4 * 80 + 3 * 8) // Adjust frame size
}
}
Заметьте, как только мы добавили модификатор .position
для TileView
, необходимость в сетке, состоящей из вложенных ForEach
, пропала. ChatGPT четко это уловил и ”вытянул“ 2D массив в 1D массив с помощью функции высшего порядка flatMap
и для единственного ForEach
использовал этот массив, полагая, что свойство position
плитки Tile
не только определяет местоположение плитки TileView
на игровой доске, но однозначно идентифицирует саму плитку Tile
. Но это не так, так как позиция position
плитки Tile
с течением игры меняется, хотя плитка остается той же самой, так что position
вовсе не является нужным нам идентификатором уникальности плитки Tile
.
Так что в GridView
в ForEach
мы убираем id: \.position
:
// Define the GridView to use TileView
struct GridView: View {
let tiles: [[Tile]]
var body: some View {
ZStack {
ForEach(tiles.flatMap { $0 }) { tile in
TileView(tile: tile)
}
}
.frame(width: 4 * 80 + 3 * 8, height: 4 * 80 + 3 * 8) // Adjust frame size
}
}
Нам нужна какая-то другая вещь, которая идентифицирует плитку навсегда и однозначно. Неважно, что произойдет с этой плиткой, неважно как сильно она поменяется, мы знаем, что это та же самая плитка, наш ForEach
всегда будет точно знать, с какой плиткой он имеет дело. Это важно для анимации.
Но как только мы уберем id: \.position,
мы получаем ошибку:
Давайте спросим ChatGPT, что нам делать с этой ошибкой:
struct Tile: Equatable, Identifiable {
// Manually
static func == (lhs: Tile, rhs: Tile) -> Bool {
return lhs.value == rhs.value
}
var value: Int
var position: Position
var id = UUID() // This provides a unique identifier for each tile
}
struct Position: Equatable {
var row: Int
var col: Int
}
// Define the GridView to use TileView
struct GridView: View {
let tiles: [[Tile]]
var body: some View {
ZStack {
ForEach(tiles.flatMap { $0 }) { tile in
TileView(tile: tile)
}
}
.frame(width: 4 * 80 + 3 * 8, height: 4 * 80 + 3 * 8) // Adjust frame size
}
}
ШАГ 7. Функция canMoveLeft — строка может перемещаться влево?
func canMoveLeft(_ row: [Tile]) -> Bool {
// Check for non-zero tiles that can slide left (i.e., zeros before non-zero tiles)
for i in 1..<row.count {
if row[i].value != 0 && row[i - 1].value == 0 {
return true // A tile can move left because there's space
}
}
// Check for adjacent equal non-zero tiles that can be merged
for i in 0..<row.count - 1 {
if row[i].value != 0 && row[i].value == row[i + 1].value {
return true // A tile can merge with its neighbor
}
}
// If no movement or merging is possible, the row cannot move left
return false
}
let row = [
Tile(value: 2, position: Position(row: 0, col: 0)),
Tile(value: 2, position: Position(row: 0, col: 1)),
Tile(value: 0, position: Position(row: 0, col: 2)),
Tile(value: 4, position: Position(row: 0, col: 3))
]
let canMove = canMoveLeft(row)
print(canMove) // Output: true (because the two '2' tiles can be merged)
Рефакторинг canMoveLeft с функциями высшего порядка
Давайте попросим ChatGPT использовать функции высшего порядка в функции canMoveLeft
:
func canMoveLeft(_ row: [Tile]) -> Bool {
// Condition 1: Check if any tile can slide left (i.e., a non-zero tile has a zero before it)
let canSlide =
zip(row.dropFirst(), row).contains { $0.value != 0 && $1.value == 0 }
// Condition 2: Check if any adjacent tiles can be merged
let canMerge =
zip(row.dropFirst(), row).contains { $0.value != 0 && $0.value == $1.value }
return canSlide || canMerge
}
func move(_ direction: Direction) {
var moved = false
// Rotate the board so we can always handle the move as a "left" move
switch direction {
case .up:
rotateLeft()
case .down:
rotateRight()
case .right:
rotateRight()
rotateRight()
case .left:
break
}
// Iterate through each row and apply sliding logic
for i in 0..<4 {
let row = tiles[i]
// Use canMoveLeft to check if the row can move or merge
if canMoveLeft(row) {
let compressedRow = compressRow(row)
let mergedRow = mergeRow(compressedRow)
if mergedRow != row {
moved = true
tiles[i] = megredRow
}
}
}
// Restore the board's orientation based on the direction
switch direction {
case .up:
rotateRight()
case .down:
rotateLeft()
case .right:
rotateRight()
rotateRight()
case .left:
break
}
if moved {
addNewTile()
checkGameOver()
}
}
ШАГ 8. Корреция функции mergeRow
Сначала нам нужно понять, как выглядит функция mergeRow
после добавления позиции position
для плитки Tile
:
private func mergeRow(_ row: [Tile]) -> [Tile] {
var newRow = row
let nonZeroTiles = row.filter { $0.value != 0 }
// If the row has less than 2 tiles return it as is
guard nonZeroTiles.count > 1 else {
return row
}
for i in 0..<row.count - 1 {
if newRow[i].value != 0 && newRow[i].value == newRow[i + 1].value {
// Merge tiles
newRow[i].value *= 2
// New zero tile on i + ! position
newRow[i + 1] = Tile(value: 0, position:
Position(row: newRow[i].position.row, col: i + 1))
}
}
// Compress the row after merging
return compressRow(newRow)
}
Замечания относительно функции
mergeRow
В нашем случае в процессе анимации мы действительно хотим видеть на UI “поглощение” плитки newRow[i]
плиткой newRow[i + 1]
, то есть плитка newRow[i + 1]
как бы “наезжает” на newRow[i]
и оказывается на месте i
, поэтому индексу i
будет соответствовать id
плитки newRow[i + 1].
Что касается индекса i + 1
, то на этом месте будет плитка с нулевым значением value
, и она тут же попадет в пул плиток с нулевыми значениями, из которых случайным образом выбирается следующая новая плитка Tile
. Плитка newRow[i + 1]
должна получить именно новый id
равный UUID(),
так как ее id
уже занят, a не присваивать ей id
плитки newRow[i]
, что может привести к ненужную анимации перемещения уже имеющейся плитки.
func mergeRow(_ row: [Tile]) -> [Tile] {
var newRow = row
let nonZeroTiles = row.filter { $0.value != 0 }
// If the row has less than 2 tiles return it as is
guard nonZeroTiles.count > 1 else {
return row
}
for i in 0..<row.count - 1 {
if newRow[i].value != 0 && newRow[i].value == newRow[i + 1].value {
// Merge tiles
newRow[i].value *= 2
// Change the id
newRow[i].id = newRow[i + 1].id
// New zero tile on i + ! position
newRow[i + 1] = Tile(value: 0, position:
Position(row: newRow[i].position.row, col: i + 1))
}
}
// Compress the row after merging
return compressRow(newRow)
}
С таким кодом в результате все работает с TileView
, но нет анимации:
Заключение
Итак, мы разобрались с логикой игры 2048. В следующем посте мы перейдем к Анимации и проектированию UI (визуализация счета, сообщение об окончании игры, оптимальное направление жеста).