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



import SwiftUI
struct TileView: View {
let tile: Tile
let tileSize: CGFloat
let padding: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: padding)
.fill(Color.colorForTile(tile.value))
.frame(width: tileSize, height: tileSize)
.overlay(
Text(tile.value > 0 ? "\(tile.value)" : "")
.font(.largeTitle)
.foregroundColor(.black)
)
.position (getTilePosition())
.animation(.easeInOut(duration: 0.2), value: tile.position)
.scaleEffect(tile.value > 0 ? 1 : 0)
.animation(.spring(), value: tile.value)
}
private func getTilePosition() -> CGPoint {
let x = CGFloat(tile.position.col) * (tileSize + padding) + tileSize / 2
let y = CGFloat(tile.position.row) * (tileSize + padding) + tileSize / 2
return CGPoint(x: x, y: y)
}
}
extension Color {
static func colorForTile(_ value: Int) -> Color {
switch value {
case 0: return Color(UIColor.systemGray5)
case 2: return Color(UIColor.systemGray4)
case 4: return Color.orange
case 8: return Color.red
case 16: return Color.purple
case 32: return Color.blue
case 64: return Color.green
case 128: return Color.yellow
case 256: return Color.pink
case 512: return Color.brown
case 1024: return Color.cyan
case 2048: return Color.indigo
default: return Color.mint
}
}
}

struct GameView: View {
@StateObject private var viewModel = GameViewModel()
let tileSize: CGFloat = 80
let padding: CGFloat = 8
var body: some View {
VStack {
Text("2048")
.font(.largeTitle)
.padding()
GridView(tiles: viewModel.tiles, tileSize: tileSize,
padding: padding)
.gesture(
DragGesture()
.onEnded { value in
withAnimation(.easeInOut) {
handleSwipe(value: value)
}
}
)
Button(action: {
withAnimation(.easeInOut) {
viewModel.resetGame()
}
}) {
Text("Restart")
.font(.title2)
.padding()
}
}
}
// Handle swipe gesture and trigger game actions
private func handleSwipe(value: DragGesture.Value) {
let threshold: CGFloat = 20
let horizontalShift = value.translation.width
let verticalShift = value.translation.height
if abs(horizontalShift) > abs(verticalShift) {
if horizontalShift > threshold {
viewModel.move(.right)
} else if horizontalShift < -threshold {
viewModel.move(.left)
}
} else {
if verticalShift > threshold {
viewModel.move(.down)
} else if verticalShift < -threshold {
viewModel.move(.up)
}
}
}
}

// Define the GridView to use TileView
struct GridView: View {
let tiles: [[Tile]]
let tileSize : CGFloat
let padding : CGFloat
var body: some View {
ZStack {
// Background grid
VStack(spacing: padding) {
ForEach(0..<4) { row in
HStack(spacing: padding) {
ForEach(0..<4) { col in
RoundedRectangle(cornerRadius:padding)
.fill(Color.colorForTile(0))
.frame(width: tileSize, height: tileSize)
}
}
}
}
// Foreground tiles (only non-zero values)
ForEach(tiles.flatMap { $0 }.filter { $0.value != 0 }){ tile in
TileView(tile: tile, tileSize: tileSize, padding: padding)
}
}
.frame(width: 4 * tileSize + 3 * padding,
height: 4 * tileSize + 3 * padding) // Adjust frame size
}
}


Результаты работы кода:
Замечания к полученной анимации:
TileView
изменился: теперь не простоText
, a прямоугольникRoundedRectangle
, на который накладываетсяText(tile.value > 0 ? "\(tile.value)" : "")
. Это сделано для того, чтобы плитка с нулевым значениемvalue == 0
не потеряла пространство.- Мы видим анимацию позиции плиток, то есть модификаторы
.position
и.animation(.easeInOut(duration: 0.2), value: tile.position)
работают, но мы не видим анимацию изменения масштаба плитки, когда она становится ненулевой или наоборот, то есть модификаторы.scaleEffect(tile.value > 0 ? 1 : 0)
и.animation(.spring(), value: tile.value)’
не работают. Это особенно отчетливо видно при отладке, когда задается режим “Медленной Анимации” (Slow Animation) :
Это происходит потому, что анимация начинается только ПОСЛЕ того, как TileView
появляется на экране. В нашем случае, если значение value
меняется с 0 на что-то отличное от 0, этот TileView
просто “выходит” на экран без всякой анимации, a если значение value меняется на 0, то TileView
исчезает с экрана, и в этом случае тоже нет никакой анимации. Для анимации такого рода прихода / ухода на / с экран используются “переходы” в виде модификатора .transition
. В данном случае действует ‘переход” по умолчанию transition(.opacity)
.
Поэтому после повторного запроса кода TileView
, ChatGPT убирает модификаторы .scaleEffect(tile.value > 0 ? 1 : 0)
и .animation(.spring(), value: tile.value)
и заменяет их на transition(.scale)
, что работает при появлении на экране или ухода с экрана Views
:

import SwiftUI
struct TileView: View {
let tile: Tile
let tileSize: CGFloat
let padding: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: padding)
.fill(Color.colorForTile(tile.value))
.frame(width: tileSize, height: tileSize)
.overlay(
Text(tile.value > 0 ? "\(tile.value)" : "")
.font(.largeTitle)
.foregroundColor(.black)
)
.position (getTilePosition())
.animation(.easeInOut(duration: 0.2), value: tile.position)
.transition(.scale)
}
private func getTilePosition() -> CGPoint {
let x = CGFloat(tile.position.col) * (tileSize + padding) + tileSize / 2
let y = CGFloat(tile.position.row) * (tileSize + padding) + tileSize / 2
return CGPoint(x: x, y: y)
}
}
extension Color {
static func colorForTile(_ value: Int) -> Color {
switch value {
case 0: return Color(UIColor.systemGray5)
case 2: return Color(UIColor.systemGray4)
case 4: return Color.orange
case 8: return Color.red
case 16: return Color.purple
case 32: return Color.blue
case 64: return Color.green
case 128: return Color.yellow
case 256: return Color.pink
case 512: return Color.brown
case 1024: return Color.cyan
case 2048: return Color.indigo
default: return Color.mint
}
}
}
Нужна ли явная анимация withAnimation для жеста .gesture



struct ContentView: View {
@StateObject private var viewModel = GameViewModel()
let tileSize: CGFloat = 80
let padding: CGFloat = 8
var body: some View {
VStack {
Text("2048")
.font(.largeTitle)
.padding()
GridView(tiles: viewModel.tiles, tileSize: tileSize,
padding: padding)
.gesture(
DragGesture()
.onEnded { value in
withAnimation { // animation
handleSwipe(value: value) // swipe handler
}
}
)
Button(action: {
withAnimation { // animation
viewModel.resetGame()
}
}) {
Text("Restart")
.font(.title2)
.padding()
}
} }
// Handle swipe gesture and trigger game actions
private func handleSwipe(value: DragGesture.Value) {. . .}
}


Вот как работает этот код:
A вот в режиме “Медленной Анимации” (Slow Animation) :
Мы видим, что появление новых плиток анимируется из середины (.center), и это выглядит не совсем хорошо, нам бы хотелось, чтобы появление новых плиток анимировалось “по месту” плиток в игровом поле.
Усовершенствованный переход .transition (.scale)
Давайте спросим, как добиться этого у ChatGPT:



struct TileView: View {
let tile: Tile
let tileSize: CGFloat
let padding: CGFloat
var body: some View {
let tilePosition = getTilePosition()
RoundedRectangle(cornerRadius:padding)
.fill(Color.colorForTile(tile.value))
.frame(width: tileSize, height: tileSize)
.overlay(
Text(tile.value > 0 ? "\(tile.value)" : "")
.font(.largeTitle)
.foregroundColor(.black)
)
.position(tilePosition)
.animation(.easeInOut(duration: 0.2), value: tile.position)
.transition(.scale(scale: 0.12).combined (with: .offset(
x: tilePosition.x - 2.0 * tileSize,
y: tilePosition.y - 2.0 * tileSize)))
}
private func getTilePosition() -> CGPoint {
let x = CGFloat(tile.position.col) * (tileSize + padding) + tileSize / 2
let y = CGFloat(tile.position.row) * (tileSize + padding) + tileSize / 2
return CGPoint(x: x, y: y)
}
}


Вот как работает этот код:
A вот в режиме “Медленной Анимации” (Slow Animation) :
Как видите здесь есть анимация и перемещения плиток и анимация масштабирования плитки при её появлении плиток на игровой доске.
ШАГ. 9 Цвета специфические для игры 2048


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

import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) =
(255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) =
(int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
static func colorForTile(_ value: Int) -> Color {. . .}
}

struct TileView: View {
let tile: Tile
let tileSize: CGFloat
let padding: CGFloat
var body: some View {
let tilePosition = getTilePosition()
RoundedRectangle(cornerRadius:padding)
.fill(Color.colorForTile(tile.value))
.frame(width: tileSize, height: tileSize)
.overlay(
Text(tile.value > 0 ? "\(tile.value)" : "")
.font(.largeTitle)
.foregroundColor(tile.value > 4 ? .white : .black) // Adjust text color based on tile value
)
.position(tilePosition)
.animation(.easeInOut(duration: 0.2), value: tile.position)
.transition(.scale(scale: 0.12).combined (with: .offset(
x: tilePosition.x - 2.0 * tileSize,
y: tilePosition.y - 2.0 * tileSize)))
}
private func getTilePosition() -> CGPoint {
let x = CGFloat(tile.position.col) * (tileSize + padding) + tileSize / 2
let y = CGFloat(tile.position.row) * (tileSize + padding) + tileSize / 2
return CGPoint(x: x, y: y)
}
}

// Define the GridView to use TileView
struct GridView: View {
let tiles: [[Tile]]
let tileSize : CGFloat
let padding : CGFloat
var body: some View {
ZStack {
// Background grid
VStack(spacing: padding) {
ForEach(0..<4) { row in
HStack(spacing: padding) {
ForEach(0..<4) { col in
RoundedRectangle(cornerRadius:padding)
.fill(Color.colorForTile(0))
.frame(width: tileSize, height: tileSize)
}
}
}
}
.padding()
.background(Color(hex: "#BBADA0"))
// Foreground tiles (only non-zero values)
ForEach(tiles.flatMap { $0 }.filter { $0.value != 0 }){ tile in
TileView(tile: tile, tileSize: tileSize, padding: padding)
}
.padding()
}
.frame(width: 4 * tileSize + 3 * padding,
height: 4 * tileSize + 3 * padding) // Adjust frame size
}
}



Замечания к полученному коду:
Мы можем разместить цвета фона игрового поля Color(hex: "#BBADA0")
и пустой плитки Color(hex: "#DC1B4")
в расширении extension Color
для дальнейшего использования в стилизации UI.
Расширение extension Color
:
extension Color {
static let colorBG = Color(hex: "#BBADA0")
static let colorEmpty = Color(hex: "#CDC1B4")
// Other code for Color extension
// . . . . . . . . . . . . . .
ШАГ 10. Изменение вероятности появления новой плитки со значениями value 2 и 4
Если мы посмотрим на код функции addNewTile()
добавления новой плитки на игровое поле tiles
, то увидим, что вероятность того, что это будет плитка со значением value равным 2 будет p = 0.5:
func addNewTile() {
var emptyPositions: [Position] = []
for row in 0..<4 {
for col in 0..<4 {
if tiles[row][col].value == 0 {
emptyPositions.append(Position(row: row, col: col))
}
}
}
if let randomPosition = emptyPositions.randomElement() {
let newValue = Bool.random() ? 2 : 4
tiles[randomPosition.row][randomPosition.col].value = newValue
}
}
В классической игре 2048 эта вероятность равна p = 0.9. Давайте попросим ChatGPT сгенерировать соответствующий этому значению вероятности p код:


func addNewTile() {
let emptyPositions = tiles.flatMap { $0 }.filter { $0.value == 0 }
// Check if there are no empty tiles available
guard !emptyPositions.isEmpty else {
return
}
// Select a random empty position
let randomIndex = Int.random(in: 0..<emptyPositions.count)
let randomPosition = emptyPositions[randomIndex].position
// Determine whether to place a 2 or a 4, with a 90% chance of placing a 2
let newValue = Double.random(in: 0..<1) < 0.9 ? 2 : 4
// Update the tiles array with the new tile
tiles[randomPosition.row][randomPosition.col] =
Tile(value: newValue, position: randomPosition)
}



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


class GameViewModel: ObservableObject {
@Published var tiles: [[Tile]] = []
@Published var isGameOver = false
@Published var score: Int = 0
init() {
resetGame()
}
func resetGame() {
score = 0
isGameOver = false
tiles = (0..<4).map { row in
(0..<4).map { col in
Tile(value: 0, position: Position(row: row, col: col))
}
}
addNewTile()
addNewTile()
}
// Other functions such as addNewTile(), rotateLeft(), etc.
// . . . . . . . . . . . . . .
}

private func mergeRow(_ row: [Tile]) -> [Tile] {
var newRow = row
var scoreToAdd = 0
for i in 0..<row.count - 1 {
if newRow[i].value != 0 && newRow[i].value == newRow[i + 1].value {
// Merge tiles
newRow[i].value *= 2
scoreToAdd += newRow[i].value
// Change the id
newRow[i].id = newRow[i + 1].id
// new zero Tile on i + ! position
newRow[i + 1] = Tile(value: 0, position:
Position(row: newRow[i].position.row, col: i + 1))
}
}
// Update the score
score += scoreToAdd
// Compress the row after merging
return compressRow(newRow)
}

struct ContentView: View {
@StateObject private var viewModel = GameViewModel()
let tileSize: CGFloat = 80
let padding: CGFloat = 8
var body: some View {
VStack {
Text("2048")
.bold()
.font(.largeTitle)
.padding()
HStack {
// Score Display
Text("Score: \(viewModel.score)")
.monospacedDigit()
.font(.title)
.foregroundColor(.accentColor)
.padding()
Spacer()
}
GridView(tiles: viewModel.tiles, tileSize: tileSize,
padding: padding)
.gesture(
DragGesture()
.onEnded { value in
withAnimation {
handleSwipe(value: value) // swipe handler
}
}
)
// Reset Button
Button (action: {
withAnimation {
viewModel.resetGame()
}
}){
Text("Restart")
.bold()
.font(.title)
}
.padding()
}
}
// Handle swipe gesture and trigger game actions
private func handleSwipe(value: DragGesture.Value) {. . .}
}



ШАГ 12. Оптимальное направление жеста для игры 2048.
Раз мы умеем вычислять увеличение счета scoreToAdd
при выполнении жеста в определенном направлении (.up
, .down
, .left
, .right
), мы можем определить оптимальное направление, в котором увеличение счета scoreToAdd
будет максимальным.
Мы можем использовать новый независимый экземпляр нашей GameViewModel
:
var gameView = GameViewModel()),
… установить ему текущее игровое поле
gameView.tiles = tiles
… и выполнить перемещение плиток
slide (_ direction: Direction)
… без добавления новой плитки, но с возвращением увеличения счета score
и сообщения moved
о том, что перемещение плиток действительно произошло. Это в некотором смысле сделает наш код более гибким и позволит определить направление direction
с максимальным приростом счета score
. Давайте так и поступим.
Функция slide (_ direction:Direction)
Для начала мы модифицируем уже существующую функцию move
:
func move (_ direction: Direction)
… которая и двигает плитки и добавляет случайным образом новую плитку, так, чтобы выделить из нее функцию slide
:
func slide(_ direction: Direction) -> (moved: Bool, score: Int)
… которая осуществляет только сдвиг плиток на игровом поле в нужном направлении, параллельно информируя о том, было ли перемещение и сколько дополнительных очков мы получили в наш счет в виде кортежа (moved: Bool, score: Int)
, состоящего из Bool
значения moved
(то есть было ли перемещение плиток и нужно ли добавлять новую плитку) и увеличения счета score:Int
при выполненном перемещении плиток в заданном направлении direction
. Такая slide
функция очень легко впишется в новую версию функции move
и даст возможность симулировать перемещение плиток для расчета оптимального направления:
func bestMoveDirection ( ) -> Direction
Давайте попросим об этом ChatGPT:



extension GameViewModel {
func slide(_ direction: Direction) -> (moved: Bool, score: Int) {
var moved = false
var totalScore = 0
// Rotate the board so we can always handle the move as a "left" move
switch direction {
case .up:
rotateLeft()
case .down:
rotateRight()
case .right:
rotateRight()
rotateRight()
case .left:
break
}
// Process each row
for i in 0..<4 {
let row = tiles[i]
if canMoveLeft(row) {
let compressedRow = compressRow(row)
let (mergedRow, rowScore) = mergeRow(compressedRow)
totalScore += rowScore
if mergedRow != row {
moved = true
tiles [i] = mergedRow
}
}
}
// Rotate back to the original orientation
switch direction {
case .up:
rotateRight()
case .down:
rotateLeft()
case .right:
rotateRight()
rotateRight()
case .left:
break
}
return (moved, totalScore)
}
// Helper functions (rotateLeft, rotateRight, compressRow, mergeRow)
// These functions should already be defined in your GameViewModel
// If not, please define them as per your existing code.
}


let (moved, score) = gameViewModel.slide(.left)
if moved {
// Optionally call addNewTile() here, if needed
}


func move(_ direction: Direction) {
let (moved, score) = slide(direction)
if moved {
self.score += score
addNewTile()
}
}

private func mergeRow(_ row: [Tile]) -> ([Tile], Int) {
var newRow = row
var scoreToAdd = 0
let nonZeroTiles = row.filter { $0.value != 0 }
// If the row has less than 2 tiles return it as is
guard nonZeroTiles.count > 1 else {
return (row, scoreToAdd)
}
for i in 0..<row.count - 1 {
if newRow[i].value != 0 && newRow[i].value == newRow[i + 1].value {
// Merge tiles
newRow[i].value *= 2
scoreToAdd += newRow[i].value
// Change the id
newRow[i].id = newRow[i + 1].id
// New zero tile on i + ! position
newRow[i + 1] = Tile(value: 0, position:
Position(row: newRow[i].position.row, col: i + 1))
}
}
// Compress the row after merging
return (compressRow(newRow), scoreToAdd)
}


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


import XCTest
@testable import Game2048ChatGPT_Test
final class Game2048ChatGPT_Tests: XCTestCase {
var viewModel: GameViewModel!
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
super.setUp()
viewModel = GameViewModel()
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
viewModel = nil
super.tearDown()
}
func testSlideLeftWithoutMerging() {
viewModel.tiles = [
[Tile(value: 2, position: Position(row: 0, col: 0)),
Tile(value: 0, position: Position(row: 0, col: 1)),
Tile(value: 4, position: Position(row: 0, col: 2)),
Tile(value: 0, position: Position(row: 0, col: 3))],
[Tile(value: 0, position: Position(row: 1, col: 0)),
Tile(value: 0, position: Position(row: 1, col: 1)),
Tile(value: 2, position: Position(row: 1, col: 2)),
Tile(value: 0, position: Position(row: 1, col: 3))],
[Tile(value: 0, position: Position(row: 2, col: 0)),
Tile(value: 2, position: Position(row: 2, col: 1)),
Tile(value: 0, position: Position(row: 2, col: 2)),
Tile(value: 4, position: Position(row: 2, col: 3))],
[Tile(value: 4, position: Position(row: 3, col: 0)),
Tile(value: 4, position: Position(row: 3, col: 1)),
Tile(value: 0, position: Position(row: 3, col: 2)),
Tile(value: 2, position: Position(row: 3, col: 3))]
]
let (moved, score) = viewModel.slide(.left)
XCTAssertTrue(moved)
XCTAssertEqual(score, 8)
XCTAssertEqual(viewModel.tiles[0][0].value, 2)
XCTAssertEqual(viewModel.tiles[0][1].value, 4)
XCTAssertEqual(viewModel.tiles[0][2].value, 0)
XCTAssertEqual(viewModel.tiles[0][3].value, 0)
XCTAssertEqual(viewModel.tiles[3][0].value, 8)
XCTAssertEqual(viewModel.tiles[3][1].value, 2)
XCTAssertEqual(viewModel.tiles[3][2].value, 0)
XCTAssertEqual(viewModel.tiles[3][3].value, 0)
}
func testSlideRightWithMerging() {
viewModel.tiles = [
[Tile(value: 2, position: Position(row: 0, col: 0)),
Tile(value: 2, position: Position(row: 0, col: 1)),
Tile(value: 4, position: Position(row: 0, col: 2)),
Tile(value: 4, position: Position(row: 0, col: 3))],
[Tile(value: 0, position: Position(row: 1, col: 0)),
Tile(value: 2, position: Position(row: 1, col: 1)),
Tile(value: 0, position: Position(row: 1, col: 2)),
Tile(value: 2, position: Position(row: 1, col: 3))],
[Tile(value: 4, position: Position(row: 2, col: 0)),
Tile(value: 4, position: Position(row: 2, col: 1)),
Tile(value: 4, position: Position(row: 2, col: 2)),
Tile(value: 4, position: Position(row: 2, col: 3))],
[Tile(value: 0, position: Position(row: 3, col: 0)),
Tile(value: 0, position: Position(row: 3, col: 1)),
Tile(value: 0, position: Position(row: 3, col: 2)),
Tile(value: 2, position: Position(row: 3, col: 3))]
]
let (moved, score) = viewModel.slide(.right)
viewModel.printBoard()
XCTAssertTrue(moved)
XCTAssertEqual(score, 32)
XCTAssertEqual(viewModel.tiles[0][0].value, 0)
XCTAssertEqual(viewModel.tiles[0][1].value, 0)
XCTAssertEqual(viewModel.tiles[0][2].value, 4)
XCTAssertEqual(viewModel.tiles[0][3].value, 8)
XCTAssertEqual(viewModel.tiles[1][0].value, 0)
XCTAssertEqual(viewModel.tiles[1][1].value, 0)
XCTAssertEqual(viewModel.tiles[1][2].value, 0)
XCTAssertEqual(viewModel.tiles[1][3].value, 4)
XCTAssertEqual(viewModel.tiles[2][0].value, 0)
XCTAssertEqual(viewModel.tiles[2][1].value, 0)
XCTAssertEqual(viewModel.tiles[2][2].value, 8)
XCTAssertEqual(viewModel.tiles[2][3].value, 8)
XCTAssertEqual(viewModel.tiles[3][0].value, 0)
XCTAssertEqual(viewModel.tiles[3][1].value, 0)
XCTAssertEqual(viewModel.tiles[3][2].value, 0)
XCTAssertEqual(viewModel.tiles[3][3].value, 2)
}
func testSlideUp() {
viewModel.tiles = [
[Tile(value: 2, position: Position(row: 0, col: 0)),
Tile(value: 2, position: Position(row: 0, col: 1)),
Tile(value: 0, position: Position(row: 0, col: 2)),
Tile(value: 4, position: Position(row: 0, col: 3))],
[Tile(value: 4, position: Position(row: 1, col: 0)),
Tile(value: 4, position: Position(row: 1, col: 1)),
Tile(value: 2, position: Position(row: 1, col: 2)),
Tile(value: 2, position: Position(row: 1, col: 3))],
[Tile(value: 2, position: Position(row: 2, col: 0)),
Tile(value: 2, position: Position(row: 2, col: 1)),
Tile(value: 4, position: Position(row: 2, col: 2)),
Tile(value: 4, position: Position(row: 2, col: 3))],
[Tile(value: 4, position: Position(row: 3, col: 0)),
Tile(value: 0, position: Position(row: 3, col: 1)),
Tile(value: 4, position: Position(row: 3, col: 2)),
Tile(value: 0, position: Position(row: 3, col: 3))]
]
let (moved, score) = viewModel.slide(.up)
XCTAssertTrue(moved)
XCTAssertEqual(score, 8)
XCTAssertEqual(viewModel.tiles[0][0].value, 2)
XCTAssertEqual(viewModel.tiles[0][1].value, 2)
XCTAssertEqual(viewModel.tiles[0][2].value, 2)
XCTAssertEqual(viewModel.tiles[0][3].value, 4)
XCTAssertEqual(viewModel.tiles[1][0].value, 4)
XCTAssertEqual(viewModel.tiles[1][1].value, 4)
XCTAssertEqual(viewModel.tiles[1][2].value, 8)
XCTAssertEqual(viewModel.tiles[1][3].value, 2)
XCTAssertEqual(viewModel.tiles[2][0].value, 2)
XCTAssertEqual(viewModel.tiles[2][1].value, 2)
XCTAssertEqual(viewModel.tiles[2][2].value, 0)
XCTAssertEqual(viewModel.tiles[2][3].value, 4)
XCTAssertEqual(viewModel.tiles[3][0].value, 4)
XCTAssertEqual(viewModel.tiles[3][1].value, 0)
XCTAssertEqual(viewModel.tiles[3][2].value, 0)
XCTAssertEqual(viewModel.tiles[3][3].value, 0)
}
func testSlideDown() {
viewModel.tiles = [
[Tile(value: 2, position: Position(row: 0, col: 0)),
Tile(value: 2, position: Position(row: 0, col: 1)),
Tile(value: 0, position: Position(row: 0, col: 2)),
Tile(value: 4, position: Position(row: 0, col: 3))],
[Tile(value: 4, position: Position(row: 1, col: 0)),
Tile(value: 4, position: Position(row: 1, col: 1)),
Tile(value: 2, position: Position(row: 1, col: 2)),
Tile(value: 2, position: Position(row: 1, col: 3))],
[Tile(value: 2, position: Position(row: 2, col: 0)),
Tile(value: 2, position: Position(row: 2, col: 1)),
Tile(value: 4, position: Position(row: 2, col: 2)),
Tile(value: 4, position: Position(row: 2, col: 3))],
[Tile(value: 4, position: Position(row: 3, col: 0)),
Tile(value: 0, position: Position(row: 3, col: 1)),
Tile(value: 4, position: Position(row: 3, col: 2)),
Tile(value: 0, position: Position(row: 3, col: 3))]
]
let (moved, score) = viewModel.slide(.down)
XCTAssertTrue(moved)
XCTAssertEqual(score, 8)
XCTAssertEqual(viewModel.tiles[3][0].value, 4)
XCTAssertEqual(viewModel.tiles[3][1].value, 2)
XCTAssertEqual(viewModel.tiles[3][2].value, 8)
XCTAssertEqual(viewModel.tiles[3][3].value, 4)
XCTAssertEqual(viewModel.tiles[2][0].value, 2)
XCTAssertEqual(viewModel.tiles[2][1].value, 4)
XCTAssertEqual(viewModel.tiles[2][2].value, 2)
XCTAssertEqual(viewModel.tiles[2][3].value, 2)
XCTAssertEqual(viewModel.tiles[1][0].value, 4)
XCTAssertEqual(viewModel.tiles[1][1].value, 2)
XCTAssertEqual(viewModel.tiles[1][2].value, 0)
XCTAssertEqual(viewModel.tiles[1][3].value, 4)
XCTAssertEqual(viewModel.tiles[0][0].value, 2)
XCTAssertEqual(viewModel.tiles[0][1].value, 0)
XCTAssertEqual(viewModel.tiles[0][2].value, 0)
XCTAssertEqual(viewModel.tiles[0][3].value, 0)
}
}


Все тесты успешно пройдены:
ШАГ 13 Структура AIGame и функция oneStepGame.
Итак, мы поступим проще, мы создадим структуру struct AIGame
и добавим туда метод oneStepGame
, в котором создадим экземпляр GameViewModel
с заданной игровой доской matrix
и выполним slide
в заданном направлении:
func oneStepGame(direction: Direction, matrix: [[Tile]]) -> (moved: Bool, score: Int)
Этот метод будем использовать для симуляции движения плиток и получения информации о том, произошло ли какое-то перемещение плиток moved
, и насколько увеличился счет score
. Давайте попросим это сделать ChatGPT:



struct AIGame {
// Method to simulate one step of the game in a given direction
func oneStepGame(direction: Direction, matrix: [[Tile]]) ->
(moved: Bool, score: Int) {
var gameViewModel = GameViewModel() // Initialize GameViewModel
gameViewModel.tiles = matrix // Set the tiles to the provided matrix
// Call the slide method on GameViewModel
let result = gameViewModel.slide(direction)
// Return the result from the slide method
return result
}
}

let aiGame = AIGame()
let matrix = // your current matrix of tiles
let result = aiGame.oneStepGame(direction: .left, matrix: matrix)
// Accessing the result
let moved = result.moved

ШАГ 14. Окончание игры 2048.
Мы можем использовать AIGame для определения окончания игры:
class GameViewModel: ObservableObject {
@Published var tiles: [[Tile]] = []
@Published var isGameOver = false
@Published var score: Int = 0
private var aiGame = AIGame()
//. . . . . . . . . . . . .
func move(_ direction: Direction) {
let (moved, score) = slide(direction)
if moved {
self.score += score
addNewTile()
}
checkGameOver()
}
func checkGameOver() {
// Logic to check if the game is over
if !canMove() {
isGameOver = true
}
}
func canMove() -> Bool {
return Direction.allCases.contains { direction in
aiGame.oneStepGame(direction: direction, matrix: tiles).moved
}
}
}

struct GameView: View {
@StateObject var viewModel: GameViewModel
var body: some View {
VStack {
Text("2048")
.font(.largeTitle)
.padding()
HStack {
// Score Display
Text("Score: \(viewModel.score)")
.monospacedDigit()
Spacer()
}
.font(.title)
.foregroundColor(.accentColor)
.padding()
// Game Over
if viewModel.isGameOver {
Text("Game Over")
.font(.title)
.foregroundColor(.red)
}
// Your game UI components here (e.g., grid view, score display).
} .padding()
}
}
Чем плох Text («GameOver»), предложенный ChatGPT:
if viewModel.isGameOver {
Text("Game Over")
.font(.largeTitle)
.foregroundColor(.red)
}
Тем, что это условный UI, и если условие не выполняется, то места на экране под него не будет выделено. И наш UI будет “скакать” по вертикали.
Что предложит ChatGPT, если его спросить об этом:




import SwiftUI
struct GameView: View {
@ObservedObject var viewModel: GameViewModel
var body: some View {
VStack {
// Your game UI components here...
Text(viewModel.isGameOver ? "Game Over" : "---- ----")
.font(.largeTitle)
.foregroundColor(viewModel.isGameOver ? .red : .clear)
.frame(maxWidth: .infinity)
.padding(.top)
// Other game UI components (e.g., grid view, score display)...
}
.padding()
}
}


Вот мой код:
// Game Over
Text(viewModel.isGameOver ? "Game Over": "____ ____")
.font(.title)
.foregroundColor(isGameOver ? .red : .clear)
Всё работает. Вот как функционирует приложение:
Шаг 15. Оптимальное направление жеста для игры 2048..
Возвращаемся к расчету оптимального направления:


enum Direction: CaseIterable {
case up, down, left, right
}
class GameViewModel: ObservableObject {
@Published var tiles : [[Tile]] = []
@Published var isGameOver = false
@Published var score = 0
private var aiGame = AIGame()
init() {
resetGame()
}
// . . . . . . . .
func bestMoveDirection() -> Direction {
var bestDirection: Direction = .left // Default to .left initially
var maxScore = 0
for direction in Direction.allCases {
let (moved, score) = aiGame.oneStepGame(direction: direction, matrix: tiles)
if moved && score > maxScore {
maxScore = score
bestDirection = direction
}
}
return bestDirection
}


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



import XCTest
@testable import YourGameModule
class GameViewModelTests: XCTestCase {
func testBestMoveDirection() {
// Example 1: Test a board where moving right is the best move
let tiles1: [[Tile]] = [
[Tile(value: 2, position: Position(row: 0, col: 0)),
Tile(value: 2, position: Position(row: 0, col: 1)),
Tile(value: 4, position: Position(row: 0, col: 2)),
Tile(value: 8, position: Position(row: 0, col: 3))],
[Tile(value: 0, position: Position(row: 1, col: 0)),
Tile(value: 4, position: Position(row: 1, col: 1)),
Tile(value: 0, position: Position(row: 1, col: 2)),
Tile(value: 4, position: Position(row: 1, col: 3))],
[Tile(value: 8, position: Position(row: 2, col: 0)),
Tile(value: 8, position: Position(row: 2, col: 1)),
Tile(value: 8, position: Position(row: 2, col: 2)),
Tile(value: 8, position: Position(row: 2, col: 3))],
[Tile(value: 16, position: Position(row: 3, col: 0)),
Tile(value: 0, position: Position(row: 3, col: 1)),
Tile(value: 16, position: Position(row: 3, col: 2)),
Tile(value: 16, position: Position(row: 3, col: 3))]
]
var viewModel = GameViewModel()
viewModel.tiles = tiles1
let bestDirection1 = viewModel.bestMoveDirection()
XCTAssertEqual(bestDirection1, .right,
"The best move should be to the right in this case.")
// Example 2: Test a board where moving down is the best move
let tiles2: [[Tile]] = [
[Tile(value: 0, position: Position(row: 0, col: 0)),
Tile(value: 0, position: Position(row: 0, col: 1)),
Tile(value: 4, position: Position(row: 0, col: 2)),
Tile(value: 8, position: Position(row: 0, col: 3))],
[Tile(value: 4, position: Position(row: 1, col: 0)),
Tile(value: 4, position: Position(row: 1, col: 1)),
Tile(value: 0, position: Position(row: 1, col: 2)),
Tile(value: 0, position: Position(row: 1, col: 3))],
[Tile(value: 2, position: Position(row: 2, col: 0)),
Tile(value: 2, position: Position(row: 2, col: 1)),
Tile(value: 4, position: Position(row: 2, col: 2)),
Tile(value: 8, position: Position(row: 2, col: 3))],
[Tile(value: 0, position: Position(row: 3, col: 0)),
Tile(value: 4, position: Position(row: 3, col: 1)),
Tile(value: 0, position: Position(row: 3, col: 2)),
Tile(value: 4, position: Position(row: 3, col: 3))]
]
viewModel.tiles = tiles2
let bestDirection2 = viewModel.bestMoveDirection()
XCTAssertEqual(bestDirection2, .down,
"The best move should be down in this case.")
// Example 3: Test a board where moving right is the best move
let tiles3: [[Tile]] = [
[Tile(value: 4, position: Position(row: 0, col: 0)),
Tile(value: 2, position: Position(row: 0, col: 1)),
Tile(value: 2, position: Position(row: 0, col: 2)),
Tile(value: 0, position: Position(row: 0, col: 3))],
[Tile(value: 2, position: Position(row: 1, col: 0)),
Tile(value: 2, position: Position(row: 1, col: 1)),
Tile(value: 0, position: Position(row: 1, col: 2)),
Tile(value: 0, position: Position(row: 1, col: 3))],
[Tile(value: 2, position: Position(row: 2, col: 0)),
Tile(value: 0, position: Position(row: 2, col: 1)),
Tile(value: 0, position: Position(row: 2, col: 2)),
Tile(value: 0, position: Position(row: 2, col: 3))],
[Tile(value: 4, position: Position(row: 3, col: 0)),
Tile(value: 4, position: Position(row: 3, col: 1)),
Tile(value: 4, position: Position(row: 3, col: 2)),
Tile(value: 4, position: Position(row: 3, col: 3))]
]
viewModel.tiles = tiles3
let bestDirection3 = viewModel.bestMoveDirection()
XCTAssertEqual(bestDirection3, .right,
"The best move should be to the right in this case.")
}
}


Благодаря этому тесту удалось уточнить код для bestMoveDirection()
:
func bestMoveDirection() -> Direction {
var bestDirection: Direction = .left
var maxScore = 0
for direction in Direction.allCases {
let (moved, score) = aiGame.oneStepGame(direction: direction,
matrix: tiles)
if moved && score >= maxScore {
maxScore = score
bestDirection = direction
}
}
return bestDirection
}
Визуализация оптимального направления bestMoveDirection


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

// Define the GridView to use TileView
struct GridView: View {
let tiles: [[Tile]]
let tileSize : CGFloat
let padding : CGFloat
//-------------------
let optimalDirection: Direction
var isShowingOptimalDirection: Bool
var body: some View {
ZStack {
// Background grid
VStack(spacing: padding) {
ForEach(0..<4) { row in
HStack(spacing: padding) {
ForEach(0..<4) { col in
RoundedRectangle(cornerRadius:padding)
.fill(Color.colorForTile(0))
.frame(width: tileSize, height: tileSize)
}
}
}
}
.padding()
.background(Color.colorBG)
// Foreground tiles (only non-zero values)
ForEach(tiles.flatMap { $0 }.filter { $0.value != 0 }) { tile in
TileView(tile: tile, tileSize: tileSize, padding: padding)
}
.padding()
if isShowingOptimalDirection {
OptimalDirectionArrow(direction: optimalDirection)
.padding()
}
}
.frame(width: 4 * tileSize + 3 * padding,
height: 4 * tileSize + 3 * padding) // Adjust frame size
}
}

struct OptimalDirectionArrow: View {
var direction: Direction
var body: some View {
GeometryReader { geometry in
let arrowLength = min(geometry.size.width, geometry.size.height) / 2
Path { path in
switch direction {
case .up:
path.move(to: CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2))
path.addLine(to: CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2 - arrowLength))
case .down:
path.move(to: CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2))
path.addLine(to: CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2 + arrowLength))
case .left:
path.move(to: CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2))
path.addLine(to: CGPoint(
x: geometry.size.width / 2 - arrowLength,
y: geometry.size.height / 2))
case .right:
path.move(to: CGPoint(x: geometry.size.width / 2,
y: geometry.size.height / 2))
path.addLine(to: CGPoint(
x: geometry.size.width / 2 + arrowLength,
y: geometry.size.height / 2))
}
}
.stroke(Color.red, lineWidth: 4)
.overlay(
ArrowheadShape(direction: direction)
.fill(Color.red)
.frame(width: 20, height: 20)
.rotationEffect( rotationAngle(for: direction))
.position(arrowheadPosition(in: geometry.size,
direction: direction, length: arrowLength))
.transaction { transaction in
transaction.animation = nil
}
)
}
}
func arrowheadPosition(in size: CGSize, direction: Direction, length: CGFloat) -> CGPoint {
switch direction {
case .up:
return CGPoint(x: size.width / 2, y: size.height / 2 - length)
case .down:
return CGPoint(x: size.width / 2, y: size.height / 2 + length)
case .left:
return CGPoint(x: size.width / 2 - length, y: size.height / 2)
case .right:
return CGPoint(x: size.width / 2 + length, y: size.height / 2)
}
}
func rotationAngle(for direction: Direction) -> Angle {
switch direction {
case .up:
return Angle.degrees(0)
case .down:
return Angle.degrees(180)
case .left:
return Angle.degrees(-90)
case .right:
return Angle.degrees(90)
}
}
}

struct ArrowheadShape: Shape {
var direction: Direction
func path(in rect: CGRect) -> Path {
var path = Path()
let tip = CGPoint(x: rect.midX, y: rect.minY)
let left = CGPoint(x: rect.minX, y: rect.maxY)
let right = CGPoint(x: rect.maxX, y: rect.maxY)
path.move(to: tip)
path.addLine(to: left)
path.addLine(to: right)
path.closeSubpath()
return path
}
}



Теперь, когда на любом этапе игры 2048 мы можем определить оптимальное направление перемещения плиток с помощью bestMoveDirection(),
мы можем заменить ручной swipe жест на автоматический запуск перемещение плиток в оптимальном направлении. и тем самым реализовать своего рода AI в игре 2048.
В следующем посте «iOS приложение игры 2048 в SwiftUI с ChatGPT 4-o. Часть 3. ИИ (AI)» мы рассмотрим ИИ алгоритмы Expectimax и Monte Carlo в игре 2048