В двух предыдущих постах мы рассмотрели создание логики игры 2048 и разработку UI с анимацией. В этом посте мы добавим ИИ (искусственный интеллект ) для игры 2048 в виде алгоритмов Expectimax и Monte Carlo. Код находится на Github.
ШАГ 16. Добавление AI в игру 2048
Добавление ИИ в игру 2048 подразумевает реализацию логики, которая может автоматически выбирать лучший ход на каждом шаге. ИИ будет, например, использовать функцию bestMoveDirection()
, которую мы ранее обсуждали, чтобы определить, какой ход выполнить, основываясь на максимальном увеличении счета. В этом случае ИИ может автоматически играть в игру 2048, делая оптимальные ходы.
Таким образом, нам понадобится метод выполнения хода ИИ, возможность запуска его автоматически с определенной периодичностью, и, переключатель для переключения между ручным режимом со swipe жестом и воспроизведением ИИ.
Но давайте сначала поймем, какие в SwiftUI есть средства запуска определенного кода автоматически через равные промежутки времени:
import SwiftUI
struct PeriodicTaskView: View {
@State private var counter = 0
// Create a timer publisher that fires every second
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Text("Counter: \(counter)")
.font(.largeTitle)
.padding()
// Example of something happening periodically
Text("This text will update every second.")
}
.onReceive(timer) { _ in
// Increment the counter every time the timer fires
counter += 1
// Place any other periodic code here
print("Timer fired. Counter is now \(counter).")
}
}
}
#Preview {
PeriodicTaskView()
}
struct GameView: View {
@ObservedObject var viewModel: GameViewModel
@State private var isAIEnabled = false
// Create a timer publisher that fires every second
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Toggle("Enable AI", isOn: $isAIEnabled)
.padding()
// Your game UI components go here...
}
.onReceive(timer) { _ in
if isAIEnabled {
let direction = viewModel.bestMoveDirection()
viewModel.move(direction)
}
}
}
}
Использование модификатора .onReceive (timer) и Timer.publish в GameView
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)
}
}
Checkmark кнопка isAIPlaying для 2048
import SwiftUI
struct GameView: View {
@ObservedObject var viewModel: GameViewModel
let tileSize: CGFloat = 80
let padding: CGFloat = 8
@State private var isAIPlaying = false
@State private var isShowingOptimalDirection = false
// Create a timer publisher that fires every second
let timer =
Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
// Your other game UI components here...
HStack {
Button(action: {
isAIEnabled.toggle()
}) {
HStack {
Image(systemName: isAIPlaying ?
"checkmark.square" : "square")
.resizable()
.frame(width: 24, height: 24)
Text(isAIPlaying ? "AI Stop" : "AI Play")
.foregroundColor(.black)
}
}
.padding()
}
// Display other game-related UI components here...
// Game Over
Text(viewModel.isGameOver ? "Game Over": " ___ ")
.font(.title)
.foregroundColor(viewModel.isGameOver ? .red : .clear)
}
.onReceive(timer){ value in
if isAIPlaying {
viewModel.executeAIMove()
}
}
}
Вот как это выглядит в нашем случае:
Код GameView:
struct GameView: View {
@ObservedObject private var viewModel = GameViewModel()
let tileSize: CGFloat = 80
let padding: CGFloat = 8
@State private var isShowingOptimalDirection = false
@State var isAIPlaying = false
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
Text("2048")
.font(.largeTitle)
.padding()
HStack {
// Score Display
Text("Score: \(viewModel.score)")
Spacer()
// AI
Button(action: {
isAIPlaying.toggle()
}) {
HStack {
Image(systemName: isAIPlaying ?
"checkmark.square" : "square")
.resizable()
.frame(width: 34, height: 34)
Text(isAIPlaying ? "AI Play" : "AI Stop")
}
}
}
.font(.title)
.foregroundColor(.accentColor)
.padding()
// Display other game-related UI components here...
}
.onReceive(timer){ value in
if isAIPlaying {
viewModel.executeAIMove()
}
}
}
// Handle swipe gesture and trigger game actions
private func handleSwipe(value: DragGesture.Value) {. . .}
}
}
A вот наш UI:
ШАГ 17. Лучшая ИИ (AI) стратегия
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 = makeMove(board, direction)
if board != newBoard {
// Recur for the next move, but now it's the tile placement's turn
maxScore =
max(maxScore, expectimax(newBoard, depth - 1, isAITurn: false))
}
}
return maxScore
}
// Random tile placement's move (chance node)
else {
var expectedScore = 0.0
let emptyTiles = findEmptyTiles(board)
// 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 {
let boardWith2 = addTile(board, tile, value: 2)
let boardWith4 = addTile(board, tile, value: 4)
// 90% probability of placing a '2' tile, 10% of placing a '4' tile
expectedScore += 0.9 * expectimax(boardWith2, depth - 1, isAITurn: true)
expectedScore += 0.1 * expectimax(boardWith4, depth - 1, isAITurn: true)
}
return expectedScore / Double(emptyTiles.count)
}
}
func evaluate(_ tiles: [[Tile]]) -> Double {
// A heuristic function to evaluate the current board state
// e.g., sum of all tile values, number of empty spaces, etc.
}
ШАГ. 18 Как оптимизировать ИИ?
func evaluateBoard(_ board: [[Tile]]) -> Double {
let monotonicityWeight = 1.0
let smoothnessWeight = 1.0
let emptyTilesWeight = 2.0
let maxTileWeight = 0.5
return monotonicity(board) * monotonicityWeight +
smoothness(board) * smoothnessWeight +
countEmptyTiles(board) * emptyTilesWeight +
getMaxTile(board) * maxTileWeight
}
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 evaluate(board)
}
// AI's move (maximize the score)
if isAITurn {
var maxScore = -Double.infinity
for direction in Direction.allCases {
let newBoard = makeMove(board, direction)
if board != newBoard {
// Recur for the next move, but now it's the tile placement's turn
maxScore =
max(maxScore, expectimax(newBoard, depth - 1, isAITurn: false))
}
}
return maxScore
}
// Random tile placement's move (chance node)
else {
var expectedScore = 0.0
let emptyTiles = findEmptyTiles(board)
// 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 {
let boardWith2 = addTile(board, tile, value: 2)
let boardWith4 = addTile(board, tile, value: 4)
// 90% probability of placing a '2' tile, 10% of placing a '4' tile
expectedScore +=
0.9 * expectimax(boardWith2, depth - 1, isAITurn: true)
expectedScore +=
0.1 * expectimax(boardWith4, depth - 1, isAITurn: true)
}
return expectedScore / Double(emptyTiles.count)
}
}
ШАГ 18. Алгоритм Expectimax
enum Direction: CaseIterable {
case up, down, left, right
}
struct Tile : Equatable, Identifiable {
var value: Int
var position: Position
var id = UUID() // This provides a unique identifier for each tile
// Manually implement Equatable conformance
static func == (lhs: Tile, rhs: Tile) -> Bool {
return lhs.value == rhs.value
}
}
struct Position: Equatable {
var row: Int
var col: Int
}
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, matrix: [[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
поиск оптимального хода:
ШАГ 19. Улучшение функции evaluate()
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]]) -> Int {
return 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
}
Объединение эвристик в функцию оценки игровой доски evaluate()
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
}
ШАГ 20. Гладкость smoothness
func smoothness(board: [[Tile]]) -> Double {
var smoothnessScore = 0.0
// Iterate through each tile on the board
for row in 0..<board.count {
for col in 0..<board[row].count {
let currentTile = board[row][col]
// Skip empty tiles
if currentTile.value == 0 {
continue
}
// Compare with the tile to the right (horizontal neighbor)
if col + 1 < board[row].count {
let rightTile = board[row][col + 1]
if rightTile.value != 0 {
smoothnessScore -= abs(log2(Double(currentTile.value)) -
log2(Double(rightTile.value)))
}
}
// Compare with the tile below (vertical neighbor)
if row + 1 < board.count {
let belowTile = board[row + 1][col]
if belowTile.value != 0 {
smoothnessScore -= abs(log2(Double(currentTile.value)) -
log2(Double(belowTile.value)))
}
}
}
}
return smoothnessScore
}
let smoothnessScore = smoothness(board)
ШАГ 21. Монотонность monotonicity
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
// print (rowMonotonicity)
}
// 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
// print (colMonotonicity)
}
return max(rowMonotonicity.increasing, rowMonotonicity.decreasing) +
max(colMonotonicity.increasing, colMonotonicity.decreasing)
}
let monotonicityScore = monotonicity(board)
Версия функции monotonicity1 с использованием функций высшего порядка
func monotonicity1(_ board: [[Int]]) -> Double {
// The same as monotonicity2
let grid:[[Int]] = board.map{$0.map{ $0 != 0 ?
Int(log2(Double($0))): 0}}
func monotonicityScore(_ arr: [Int]) -> (Double, Double) {
let arrNonZero = arr.filter { $0 != 0 }
let increasingScore = zip(arrNonZero, arrNonZero.dropFirst())
.filter {$0 >= $1}.map {Double($1 - $0) }.reduce(0.0, +)
let decreasingScore = zip(arrNonZero, arrNonZero.dropFirst())
.filter {$0 <= $1}.map {Double($0 - $1) }.reduce(0.0, +)
return (increasingScore, decreasingScore)
}
let rowScores = grid.map(monotonicityScore)
let rowIncreasing = rowScores.map {$0.0}
let rowDecreasing = rowScores.map {$0.1}
let columns = (0..<grid[0].count).map { col in grid.map { $0[col] } }
let columnScores = columns.map(monotonicityScore)
let columnIncreasing = columnScores.map {$0.0}
let columnDecreasing = columnScores.map {$0.1}
let totalScore = max (rowIncreasing.reduce(0.0, +),
rowDecreasing.reduce(0.0, +)) +
max (columnIncreasing.reduce(0.0, +),
columnDecreasing.reduce(0.0, +))
return totalScore
}
// Test case for strictly increasing row monotonicity
func testIncreasingRowMonotonicity() {
let grid = [
[2, 4, 8, 16],
[8, 16, 0, 32],
[32, 16, 64, 8],
[8, 16, 32, 64]
]
let result = aiGame.monotonicity(grid)
XCTAssertEqual(result, -9.0,
"Monotonicity score for increasing row is incorrect.")
let result2 = aiGame.monotonicity2(grid)
XCTAssertEqual(result2, -9.0,
"Monotonicity score for increasing row is incorrect.")
let result1 = aiGame.monotonicity1(grid)
XCTAssertEqual(result1, -9.0,
"Monotonicity score for increasing row is incorrect.")
}
// Test case for monotonicity in rows
func testColumnMonotonicity1() {
let grid = [
[2, 4, 8, 16],
[4, 8, 16, 32],
[2, 0, 2, 0],
[0, 4, 8, 16]
]
let result = aiGame.monotonicity(grid)
XCTAssertEqual(result, -7.0,
"Monotonicity score for increasing column is incorrect.")
let result2 = aiGame.monotonicity2(grid)
XCTAssertEqual(result2, -6.0,
"Monotonicity score for increasing row is incorrect.")
let result1 = aiGame.monotonicity1(grid)
XCTAssertEqual(result1, -6.0,
"Monotonicity score for increasing row is incorrect.")
}
// Test case for strictly decreasing row monotonicity
func testDecreasingRowMonotonicity() {
let grid = [
[16, 8, 4, 2],
[4, 0, 2, 0],
[8, 4, 0, 0],
[32, 4, 8, 16]
]
let result = aiGame.monotonicity(grid)
XCTAssertEqual(result, -6,
"Monotonicity score for decreasing row is incorrect.")
let result2 = aiGame.monotonicity2(grid)
XCTAssertEqual(result2, -6,
"Monotonicity score for increasing row is incorrect.")
let result1 = aiGame.monotonicity1(grid)
XCTAssertEqual(result1, -6,
"Monotonicity score for increasing row is incorrect.")
}
// Test case for mixed values row
func testMixedRowMonotonicity() {
let grid = [
[2, 16, 4, 8],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
]
let result = aiGame.monotonicity(grid)
XCTAssertEqual(result, -2.0,
"Monotonicity score for mixed row is incorrect.")
let result2 = aiGame.monotonicity2(grid)
XCTAssertEqual(result2, -2.0,
"Monotonicity score for increasing row is incorrect.")
let result1 = aiGame.monotonicity1(grid)
XCTAssertEqual(result1, -2.0,
"Monotonicity score for increasing row is incorrect.")
}
// Test case for monotonicity in columns
func testColumnMonotonicity() {
let grid = [
[2, 8, 0, 0],
[4, 16, 0, 0],
[0, 0, 0, 0],
[16, 32, 0, 0]
]
let result = aiGame.monotonicity(grid)
XCTAssertEqual(result, -5.0,
"Monotonicity score for increasing column is incorrect.")
let result2 = aiGame.monotonicity2(grid)
XCTAssertEqual(result2, 0.0,
"Monotonicity score for increasing row is incorrect.")
let result1 = aiGame.monotonicity1(grid)
XCTAssertEqual(result1, 0.0,
"Monotonicity score for increasing row is incorrect.")
}
// Test case for penalty when zeros are present
func testZerosPenaltyMonotonicity() {
let grid = [
[2, 0, 8, 16],
[4, 0, 0, 0],
[0, 0, 0, 4],
[0, 0, 0, 0]
]
let result = aiGame.monotonicity(grid)
XCTAssertEqual(result, -3.0,
"Monotonicity score should penalize zeros.")
let result2 = aiGame.monotonicity2(grid)
XCTAssertEqual(result2, -1.0,
"Monotonicity score for increasing row is incorrect.")
let result1 = aiGame.monotonicity1(grid)
XCTAssertEqual(result1, -1.0,
"Monotonicity score for increasing row is incorrect.")
}
// Test case for empty grid
func testEmptyGridMonotonicity() {
let grid = [
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
]
let result = aiGame.monotonicity(grid)
XCTAssertEqual(result, 0.0,
"Monotonicity score for empty grid is incorrect.")
let result2 = aiGame.monotonicity2(grid)
XCTAssertEqual(result2, 0.0,
"Monotonicity score for increasing row is incorrect.")
let result1 = aiGame.monotonicity1(grid)
XCTAssertEqual(result1, 0.0,
"Monotonicity score for increasing row is incorrect.")
}
ШАГ 21 Разница между монотонностью Monotonicity и “гладкостью” Smoothness.
[64, 32, 16, 8]
[32, 16, 8, 4]
[16, 8, 4, 2]
[8, 4, 2, 0]
[16, 16, 8, 8]
[8, 8, 4, 4]
[4, 4, 2, 2]
[2, 2, 0, 0]
ШАГ 22. Эвристика в виде Snake (Змея) паттерна
Два способа организации игровой доски в виде Snake паттерна показаны на рисунке:
Матрица весов для Snake паттерна игры 2048
[15, 14, 13, 12]
[8, 9, 10, 11]
[7, 6, 5, 4]
[0, 1, 2, 3]
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 monotonicityWeight = 1.0
let smoothnessWeight = 0.1
let emptyTilesWeight = 5.7
let maxTileWeight = 0.5
let emptyTilesCount =
Double(board.flatMap{$0}.filter{$0.value == 0}.count)
return monotonicity(board) * monotonicityWeight +
smoothness(board) * smoothnessWeight +
emptyTilesCount * emptyTilesWeight +
maxTileInCorne() * maxTileWeight +
snakeHeuristic (board)
}
[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]
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)]
]
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
}
Вот наша evaluate()
функция:
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)
}
ШАГ 23. Метод Monte Carlo как ИИ для игры 2048
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.
}
ШАГ 24. Усовершенствование Monte Carlo как ИИ для игры 2048
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
}
ШАГ 25. Сделай рефакторинг runSimulationsParallel с async await
func runSimulationsParallel(board: [[Tile]], direction: Direction, simulations: Int, depth: Int) async -> Double {
var totalScore = 0.0
// Create an array of tasks for parallel execution
await withTaskGroup(of: Double.self) { taskGroup in
for _ in 0..<simulations {
taskGroup.addTask {
let gameBoard = Game(matrix: matrix)
let (moved, _) = gameBoard.slide(direction)
if moved {
let score = randomGame(board:gameBoard.tiles, depth: depth)
return score
} else {
return 0.0 // If no move is made, return a score of 0
}
}
}
// Collect all the results from the tasks
for await score in taskGroup {
totalScore += score
}
}
return totalScore / Double(simulations)
}
ШАГ 26. Параллелизм expectimax с async await
import Foundation
// 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 (мне это вроде как не требовалось и я проверяла их с точки зрения приемлемости для новичков, но это реально впечатляет и даже для меня открывало что-то новое!!!).
Недостатки:
- Хотя держит контекст решаемой задачи в процессе одной сессии, код полного приложения приходится собирать по кусочкам, это вам не Claude 3.5 Sonnet. Однако к настоящему моменту появился новый способ взаимодействия — ChatGPT 4 Canvas, который полностью держит разрабатываемый проект, но я еще не пробовала.
- Иногда «увиливает» от прямо поставленного вопроса.
- Редко, но совершает непростительные ошибки в коде, которые приходится проверять.
При работе над iOS приложением игры 2048 с помощью chatGPT мне ни разу не пришлось обращаться к Google или StackOverFlow, так что ChatGPT вполне может заменить эти два инструмента при разработке iOS приложений.