Этот сайт использует файлы cookies. Продолжая просмотр страниц сайта, вы соглашаетесь с использованием файлов cookies. Если вам нужна дополнительная информация, пожалуйста, посетите страницу Политика файлов Cookie
Subscribe
Прямой эфир
Cryptocurrencies: 9944 / Markets: 87551
Market Cap: $ 2 310 356 654 676 / 24h Vol: $ 90 815 107 536 / BTC Dominance: 59.397492155094%

Н Новости

Азартная разработка iOS приложения игры 2048 с ChatGPT

Я хочу поделиться с вами опытом создания "с нуля" iOS приложения известной игры 2048 с элементами ИИ (искусственного интеллекта) в SwiftUI с помощью ChatGPT .

В своем классическом варианте, когда играет пользователь с помощью жестов (вверх, вниз, вправо, влево), это довольно простая игра и создать полноценное iOS приложение для такой игры 2048 можно за короткое время, при этом код будет понятен каждому. Но простые правила игры только подталкивают к созданию оптимальных алгоритмов решения игры 2048, то есть к созданию ИИ, который мог бы играть в эту игру автоматически и максимизировать счет игры в разумные сроки.

Мне хотелось написать игру 2048 именно на SwiftUI, пользуясь его прекрасной и мощной анимацией и приличным быстродействием , a также предоставить в распоряжения пользователя не только “ручной” способ игры, когда Вы руководите тем, каким должен быть следующий ход: вверх, вниз, влево и вправо, но и ряд алгоритмов с оптимальной стратегией (метода Монте-Карло, стратегий поиска по деревьям (Minimax, Expectimax) ), позволяющих АВТОМАТИЧЕСКИ выполнять ходы - вверх, вниз, влево и вправо - и добиться плитки с числом 2048 и более (эти алгоритмы и называют алгоритмами “искусственного интеллекта” (ИИ)). Необходимым элементом ИИ является алгоритм поиска, который позволяет смотреть вперед на возможные будущие позиции, прежде чем решить, какой ход он хочет сделать в текущей позиции.

2048 - это очень известная игра, и мне не нужно было объяснять ChatGPT ее правила, он сам всё про неё знает. Кроме того, оказалось, что ChatGPT прекрасно осведомлен об ИИ алгоритмах для игры 2048, так что мне вообще не пришлось описывать ChatGPT контекст решаемой задачи. И он предлагал мне множество таких неординарных решений, которые мне пришлось бы долго выискивать в научных журналах.

Чтобы вы в дальнейшем смогли оценить эти решения, я кратко напомню правила игры 2048.

Сама игра проста. Вам дается игровое поле размером 4×4, где каждая плитка может содержать число внутри себя.

Рис.1 Пример хода в 2048. После хода “сдвиг влево” (left) на левой доске. Доска слева станет той, что расположена на рис. справа.
Рис.1 Пример хода в 2048. После хода “сдвиг влево” (left) на левой доске. Доска слева станет той, что расположена на рис. справа.

Числа на игровом поле всегда будут степенью двойки. Изначально есть только две плитки с номерами 2 или 4. Вы можете менять игровое поле, нажимая на клавиши со стрелками - вверх, вниз, вправо, влево - и все плитки будут двигаться в этом направлении, пока не будет остановлены либо другой плиткой, либо границей сетки. Если две плитки с одинаковыми числами столкнутся во время движения, они сольются в новую плитку с их суммой. Новая плитка не может повторно слиться с другой соседней плиткой во время этого перемещения. После перемещения новая плитка с числом 2 или 4 случайным образом появится на одной из пустых плиток, после чего игрок делает новый ход.

Цель игры состоит в том, чтобы достичь плитки с числом 2048, но её можно рассматривать более широко и достигать плитку с максимально возможным числом. На самом деле существует система подсчета очков, применяемая к каждому ходу. Счет игрока начинается с нуля и увеличивается всякий раз, когда две плитки объединяются, на значение нового числа объединенной плитки. Если нет пустой ячейки и больше нет допустимых ходов, то игра заканчивается.

Итак, моя задача заключалась не только в том, чтобы создать движок игры 2048 на Swift, но и разработать UI c анимацией движения плиток с помощью SwiftUI, a также задействовать ИИ (алгоритмы Expectimax и Monte Carlo) в игре 2048. При этом я хотела максимально использовать возможности ChatGPT.

В статье подробно рассмотрены следующие этапы разработки такого iOS приложения игры 2048 с помощью ChatGPT:

  1. Логика игры без анимации.

  2. Разработка UI (анимация перемещения плиток и появления новых случайных плиток, отображение оптимального направления перемещения плиток на игровом поле).

  3. Добавление AI (алгоритмы Greedy, Expectimax и MonteCarlo) в игру 2048 c автоматическим запуском.

    На третьем этапе я получила от ChatGPT два алгоритма ИИ - Expectimax и Monte Carlo - и их варианты, которые позволяют получать очень приличные результаты - плитки со значениями 4096 и 8092.

    Алгоритм Expectimax в действии
    Алгоритм Expectimax в действии
Алгоритм Monte Carlo в действи
Алгоритм Monte Carlo в действи

Заметьте, какой разный рисунок игры у этих двух совершенно разных ИИ алгоритмов, стремящихся к одному и тому же результату.

Я использовала бесплатную версию ChatGPT 4-o.mini (не самую мощную). Все вопросы задавались на английском и ответы от ChatGPT 4-o.mini тоже были получены на английском, но впоследствие переведены самим же ChatGPT для вас на русский.

В плане движка игры 2048 и UI с анимацией ChatGPT дал возможность очень быстро получить понятный лаконичный код с отличными функциональными возможностями, хотя без ошибок не обошлось. У меня было очень мало ошибок, но они были завуалированными, которые не так-то просто найти. Вот тогда и приходилось прибегать к использованию тестов, которые ChatGPT генерирует отлично.

В плане ИИ для игры 2048 ChatGPT не просто поразил меня своей эрудицией, он предоставил мне готовыми такие ИИ алгоритмы, как Expextimax и Monte Carlo со множеством оптимизирующих возможностей для получения более значимого результата. И это не просто советы, все это сопровождается соответствующим кодом. В частности, использование Swift многопоточности позволило сделать код более эффективным, особенно для больших и глубоких деревьев поиска в игре 2048.

Еще более ценно то, что этот материал можно эффективно использовать для обучения программированию на Swift и SwiftUI не хуже Стэнфордского курса.

Полный протокол взаимодействия с ChatGPT при создании iOS приложения игры 2048 представлен в 3-х постах на моем сайте:

А код находится на GitHub. В этой статье я представляю лишь отдельные фрагменты этого протокола, чтобы вы могли почувствовать, насколько полезным может быть ChatGPT как при разработки, так и при обучении.

Логика игры 2048

Итак, первый запрос к ChatGPT:

ШАГ 1. Начало

321bae66d996a2483a3c91f8e53f98be.png
b0133adedd7eb0460a43fb66038bb007.png
код GameViwModel
import SwiftUI

enum Direction {
    case up, down, left, right
}

class GameViewModel: ObservableObject {
    @Published var board: [[Int]]
    @Published var gameOver = false

    init() {
        board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
        addNewTile()
        addNewTile()
    }

    func addNewTile() {
        var emptyTiles: [(Int, Int)] = []
        for row in 0..<4 {
            for col in 0..<4 {
                if board[row][col] == 0 {
                    emptyTiles.append((row, col))
                }
            }
        }
        if let randomTile = emptyTiles.randomElement() {
            board[randomTile.0][randomTile.1] = Bool.random() ? 2 : 4
        }
    }
    
    func resetGame(_ direction: Direction) {
        // Reset the board to the initial state
        board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
        gameOver = false
        addNewTile()
        addNewTile()

    }

    func move(_ direction: Direction) {
        // Implement the movement logic here
        // This will include merging tiles and updating the board state
        // After moving tiles, call addNewTile() to add a new tile
    }

    func checkGameOver() {
        // Implement the logic to check if the game is over
        // This will involve checking if there are any valid moves left
    }
}
9568d6603181615564cad8aa5362aaa8.pngкод GameView
struct GameView: View {
    @StateObject private var viewModel = GameViewModel()

    var body: some View {
        VStack {
            Text("2048")
                .font(.largeTitle)
                .padding()
            
            GridView(board: viewModel.board)
                .padding()
            
            Button(action: {
                // Implement restart functionality
                viewModel = GameViewModel()
            }) {
                Text("Restart")
                    .font(.title)
                    .padding()
            }
        }
        .onAppear {
            // Initialize game state
        }
        .onReceive(NotificationCenter.default.publisher(for: 
                         UIResponder.keyboardWillShowNotification)) { _ in
            // Handle keyboard events or gestures here
        }
    }
}

struct GridView: View {
    let board: [[Int]]

    var body: some View {
        VStack(spacing: 8) {
            ForEach(0..<4) { row in
                HStack(spacing: 8) {
                    ForEach(0..<4) { col in
                        TileView(value: board[row][col])
                    }
                }
            }
        }
    }
}

struct TileView: View {
    let value: Int

    var body: some View {
        Text(value == 0 ? "" : "\(value)")
            .frame(width: 80, height: 80)
            .background(Color.gray.opacity(0.4))
            .cornerRadius(10)
            .font(.title)
            .foregroundColor(.black)
    }
}
5e218cf514d8b0ede7befd2f03b497cc.pngкод GameView с жестом DragGesture
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()
            }
        }
    }
}
424c31f88a56b0e10ebc6aade57b46dc.png

Копируем полученный код в наш проект в Xcode:

1ff343d0fd7c14bc90233cefe94acee3.gif

Мы получили вполне себе работающий код генерации UI с необходимыми “заглушками” для логики игры 2048, которые придется наполнять кодом.

ШАГ 2. Функция move()

Получим код для самой главной функции move() с помощью CharGPT:

7647ecb76ca0d4d18275a82922cc913c.png6a4d7ee39ae07935298e7d1c90003f21.pngФункция move (_ direction:Direction)
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()
    }
}
541f1d393644fa8ee4043ff2cc5c1302.png
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]
        }
    }
}
a07e27d701e34973c67845f07665dc3c.png
func compressRow(_ row: [Int]) -> [Int] {
    let filteredRow = row.filter { $0 != 0 }
    let zeros = Array(repeating: 0, count: 4 - filteredRow.count)
    return filteredRow + zeros
}
a1d5a1db40afaea37188c04353999af4.png
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
}
ca4fe3246ccae7f4cf4331e5a1dd677b.png3608c211a4e6250624cdfeb4ce03bb92.png0b5287fe3c30ff71be57b2357e52f226.png138e91706be6c537165c2cf91e0ae4a0.pngНовый GameViewModel:
enum Direction {
    case up, down, left, right
}

class GameViewModel: ObservableObject {
    @Published var board: [[Int]]
    @Published var isGameOver = false

    init() {
        board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
        addNewTile()
        addNewTile()
    }

    func addNewTile() {
        var emptyTiles: [(Int, Int)] = []
        for row in 0..<4 {
            for col in 0..<4 {
                if board[row][col] == 0 {
                    emptyTiles.append((row, col))
                }
            }
        }
        if let randomTile = emptyTiles.randomElement() {
            board[randomTile.0][randomTile.1] = Bool.random() ? 2 : 4
        }
    }

    func move(_ direction: Direction) {
        var moved = false

        switch direction {
        case .up:
            rotateLeft()
        case .down:
            rotateRight()
        case .right:
            rotateRight()
            rotateRight()
        case .left:
            break
        }

        for i in 0..<4 {
            let compressedRow = compressRow(board[i])
            let mergedRow = mergeRow(compressedRow)
            let finalRow = compressRow(mergedRow)
            if finalRow != board[i] {
                moved = true
            }
            board[i] = finalRow
        }

        switch direction {
        case .up:
            rotateRight()
        case .down:
            rotateLeft()
        case .right:
            rotateRight()
            rotateRight()
        case .left:
            break
        }

        if moved {
            addNewTile()
            checkGameOver()
        }
    }

    func checkGameOver() {
        // Implement the logic to check if the game is over
        // This will involve checking if there are any valid moves left
    }

    func resetGame() {
        board = Array(repeating: Array(repeating: 0, count: 4), count: 4)
        gameOver = false
        addNewTile()
        addNewTile()
    }

    private func rotateLeft() {
        board = (0..<4).map { i in
            (0..<4).map { j in
                board[j][3 - i]
            }
        }
    }

    private func rotateRight() {
        board = (0..<4).map { i in
            (0..<4).map { j in
                board[3 - j][i]
            }
        }
    }

    private func compressRow(_ row: [Int]) -> [Int] {
        let filteredRow = row.filter { $0 != 0 }
        let zeros = Array(repeating: 0, count: 4 - filteredRow.count)
        return filteredRow + zeros
    }

    private func mergeRow(_ row: [Int]) -> [Int] {
        var newRow = row
        for i in 0..<3 {
            if newRow[i] != 0 && newRow[i] == newRow[i + 1] {
                newRow[i] *= 2
                newRow[i + 1] = 0
            }
        }
        return newRow
    }
}
50c43894fbf7b0e3ab528c0803461447.png09b84b83f3be01b80cdf47a5911a9c3d.gif

Круто!!
За 2 шага мы получили работающий вариант игры 2048. Перемещение и слияние плиток работает, но без анимации, кроме того, нет индикации об окончании игры и счета. Давайте сначала добавим анимацию плиток на игровом поле.

UI и анимация игры 2048

ШАГ 3. Подготовка модели к анимации перемещения плиток

Давайте проведем подготовку к анимации перемещения плиток и наделим наши плитки не только значением value, но и положением position плитки на игровом поле. Теперь нашу плитку будет моделировать структура struct Tile, в которой в качестве свойства плитки position используется структура struct Position { var row: int var col: Int}, a игровое поле представлено двухмерным массивом var board: [[Tile]]:

19f5dc9240be62048e99a52c1b7aa389.pnga2bbd960a10cf63b4d1e68649149cae6.png
import SwiftUI

struct Position {
    var row: Int
    var col: Int
}

struct Tile {
    var value: Int
    var position: Position
}
Обновление GameViewModel в связи с использованием Tile:
import SwiftUI

enum Direction {
    case up, down, left, right
}

class GameViewModel: ObservableObject {
    @Published var tiles: [[Tile]] = []
    @Published var isGameOver = false

   init() {
        resetGame()
    }
    
   func resetGame() {
        isGameOver = false
        tiles = (0..<4).map { row in
                (0..<4).map { col in
                    Tile(value: 0, position: Position(row: row, col: col))
                }
            }
        addNewTile()
        addNewTile()
        }

    func addNewTile() {
        var emptyPositions: [Position] = []
        for row in 0..<4 {
            for col in 0..<4 {
                if tiles[row][col].value == 0 {
                    emptyPositions.append(Position(row: row, col: col))
                }
            }
        }
        if let randomPosition = emptyPositions.randomElement() {
            let newValue = Bool.random() ? 2 : 4
            tiles[randomPosition.row][randomPosition.col].value = newValue
        }
    }

    func move(_ direction: Direction) {
        var moved = false

        // Rotate the board so we can always handle the move as a "left" move
        switch direction {
        case .up:
            rotateLeft()
        case .down:
            rotateRight()
        case .right:
            rotateRight()
            rotateRight()
        case .left:
            break
        }

        for i in 0..<4 {
            let row = getRow(i)
            let compressedRow = compressRow(row)
            let mergedRow = mergeRow(compressedRow)
            if mergedRow != row {
                moved = true
                updateRow(i, with: mergedRow)
            }
        }

        switch direction {
        case .up:
            rotateRight()
        case .down:
            rotateLeft()
        case .right:
            rotateRight()
            rotateRight()
        case .left:
            break
        }

        if moved {
            addNewTile()
            checkGameOver()
        }
    }

    func checkGameOver() {
        // Implement the logic to check if the game is over
    }

    private func rotateLeft() {
        tiles = (0..<4).map { col in
            (0..<4).map { row in
                var tile = tiles[row][3 - col]
                 tile.position = Position(row: col, col: row)
                 return tile
            }
        }
    }

    private func rotateRight() {
        tiles = (0..<4).map { col in
            (0..<4).map { row in
                 var tile = tiles[3 - row][col]
                 tile.position = Position(row: col, col:  row)
                 return tile
            }
        }
    }

    private func getRow(_ index: Int) -> [Tile] {
        return tiles[index]
    }

    private func updateRow(_ index: Int, with newRow: [Tile]) {
        for col in 0..<4 {
            tiles[index][col] = newRow[col]
        }
    }

    private func compressRow(_ row: [Tile]) -> [Tile] {
        let nonZeroTiles = row.filter { $0.value != 0 }

       // Guard to check if we need to compress
       guard !nonZeroTiles.isEmpty, nonZeroTiles.count != 4,
          !(nonZeroTiles.count == 1 && nonZeroTiles[0].position.col == 0) 
        else {
            // If the row is already in a compressed state, return it as is
            return row
        }

        // Create new row with non-zero tiles and update their positions
        let newRow: [Tile] = nonZeroTiles.enumerated().map { (index, tile) in
            var updatedTile = tile
            updatedTile.position = 
                               Position(row: tile.position.row, col: index)
            return updatedTile
        }

        // Add zeros to the end of the row with updated positions
        let zeros = (newRow.count..<row.count).map { colIndex in
            Tile(value: 0, position: 
                 Position(row: row[0].position.row, col: colIndex))
        }

        return newRow + zeros
    }

    private func mergeRow(_ row: [Tile]) -> [Tile] {
        var newRow = row
        
       let nonZeroTiles = row.filter { $0.value != 0 }
        
       // If the row has less than 2 tiles return it as is
        guard nonZeroTiles.count > 1 else {
            return row
        }

        for i in 0..<row.count - 1 {
            if newRow[i].value != 0 && newRow[i].value == newRow[i + 1].value {
                
                // Merge tiles
                newRow[i].value *= 2
                
                // New zero tile on i + ! position
                newRow[i + 1] = Tile(value: 0, position: 
                          Position(row: newRow[i].position.row, col: i + 1))
            }
        }

        // Compress the row after merging
        return compressRow(newRow)
    }
}
fb9c4390423da3c9e931f7092c2a16ca.png7463c1af5eb051067ed68350b88d7425.png82bd8451cc19b7acd69b61beb4a5d4ba.pngGameView c TileView
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 получаем ошибку:

0fcc0f47d7307b47278009ac917779c3.png

Спрашиваем ChatGPT, как её исправить:

fd2008e86c39c9767700f84424509202.png8c4c26e3899b9db5dd17858a5fc44aa8.pngc0db8f92e1cc5be24f28fde9ccec9415.png
struct Position:Equatable {
    var row: Int
    var col: Int
}

struct Tile: Equatable {
    var value: Int
    var position: Position
}
4d0c860ef97bdd72c559095f6f4b2b37.pngfe825fee70ba2600a951aa1559289805.png
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
}
08612fa07b9f993f4e8df548b7fff3b5.png

Мы использовали соответствие структуры Position протоколу Equatable, которое выполняется Swift автоматически, и ручную реализацию c использованием только свойство value, что необходимо для логики игры 2048 :

struct Tile: Equatable {
    var value: Int
    var position: Position

    // Manually implement Equatable conformance
    static func == (lhs: Tile, rhs: Tile) -> Bool {
        return lhs.value == rhs.value
    }
}

struct Position: Equatable {
    var row: Int
    var col: Int
}

Все работает как и прежде, но с новой структурой Tile со свойством position:

1d1e00bac36d235e82f6a925da6bd5a6.gif

Однако для анимации нам нужно работать с изображением плитки TileView на игровой доске, и первое, что нам нужно сделать, - это добавить позиционирование плитки TileView на игровой доске с помощью модификатора .position, используя свойство position самой модели Tile.

ШАГ 4. Модификатор .position для TileView

945346cac6bf65ee5874b61bdac24fa3.pngf34b19a44f2d97e9d706044986c8cbdc.png8e0b7c6dcf6bd78df0ae987b2cfbf476.pngкод TileView и GridView
import SwiftUI

// Define the TileView
struct TileView: View {
    let tile: Tile

    var body: some View {
        Text(tile.value == 0 ? "" : "\(tile.value)")
            .frame(width: 80, height: 80)
            .background( Color.gray.opacity(0.4))
            .cornerRadius(10)
            .font(.title)
            .foregroundColor(.black)
            .position(getTilePosition())
    }

    private func getTilePosition() -> CGPoint {
        let tileSize: CGFloat = 80 // Adjust based on tile size and padding
        let spacing: CGFloat = 8 // Space between tiles

        let x = 
            CGFloat(tile.position.col) * (tileSize + spacing) + tileSize / 2
        let y = 
            CGFloat(tile.position.row) * (tileSize + spacing) + tileSize / 2

        return CGPoint(x: x, y: y)
    }
}

// Define the GridView to use TileView
struct GridView: View {
    let tiles: [[Tile]]

    var body: some View {
        ZStack {
            ForEach(tiles.flatMap { $0 }, id: \.position) { tile in
                TileView(tile: tile)
            }
        }
        .frame(width: 4 * 80 + 3 * 8, height: 4 * 80 + 3 * 8) // Adjust frame size
    }
}
26dfd79b1ae1133d2ec9d9254005810d.png

ШАГ 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.

// 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, мы получаем ошибку:

d25647ffb756cfe214fc70e9cd06761d.png

Давайте спросим ChatGPT, что нам делать с этой ошибкой:

91adc3205b641b6dc8936c14a32b5d24.png36a1e03424776e5130cff0ac250a1191.png5fd2e0594fd760e6baedcd134e11f22d.png
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
}
3c322b5b562455b530290955dc9d88e4.png
// 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
    }
}
b4cf32f80a26c622ead6f52145287391.pngfd15febac03f7afb77cc6a6b6b1ea5d7.gif

Шаг 5. Анимация

2e33f54d750184f1e32806c066ea829f.png27fa5924f38dabe87660138ecae9f12b.png10a6a58c3f334af7be259820b17bf4c9.pngTileView с неявной анимацией и модификатором перехода .transition
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
        }
    }
}
25aeffb9261e5b9f264007927b365794.pngGameView c явной анимацией withAnimation
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)
            }
        }
    }
}
69cac060ea1ae84b9244a52af8672c47.pngСкрытый текст
// 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
    }

}
096210770e4a716690d4d628b7efb5fc.png770133daeffeb2bc216e538779d644cb.png

Вот как работает этот код:

e864e88f78ffb6ea0e27621af89f5dd5.gif

A вот в режиме “Медленной Анимации” (Slow Animation) :

fc3de1399a6366f5506d2f209ff95f45.gif

Мы видим, что появление новых плиток анимируется из середины (.center), и это выглядит не совсем хорошо, нам бы хотелось, чтобы появление новых плиток анимировалось “по месту” плиток в игровом поле.

Усовершенствованный переход .transition (.scale)

Давайте спросим, как добиться этого у ChatGPT:

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

Вот как работает этот код:

539e05f3e9e740e5d9e4679a033f48f4.gif

A вот в режиме “Медленной Анимации” (Slow Animation) :

5ccad4da0e122112c4636b07ba5c996b.gif

Следующие шаги я не буду рассматривать здесь, с ними можно подробно познакомиться в посте :

ШАГ 6. Цвета специфические для игры 2048

63cbde1e3d0dc3e9eda6e0a4933b53b8.png

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

a23a4419ff2ba869743ea8f06f240641.png

ШАГ 8. Окончание игры 2048.

b9b46b7a474aaf3d7a436febb95db2f7.png99e2b96aecbe4a54e77df52a33d7ff52.png

ШАГ 9. Оптимальное направление жеста для игры 2048.

2d309805f6918b39c05a88d6416cccf1.png

Результат работы приложения после применения вышеуказанных шагов:

94466861071880e2d15a3f8103b2a0da.gif

Теперь, когда на любом этапе игры 2048 мы можем определить оптимальное направление перемещения плиток с помощью bestMoveDirection(), мы можем заменить ручной swipe жест на автоматический запуск перемещение плиток в оптимальном направлении. и тем самым реализовать своего рода "жадный" (greedy) ИИ (AI) алгоритм в игре 2048.

Искусственный интеллект (AI) в игре 2048

ШАГ 10. Добавление AI в игру 2048

Добавление ИИ в игру 2048 подразумевает реализацию логики, которая может автоматически выбирать лучший ход на каждом шаге. ИИ будет, например, использовать функцию bestMoveDirection(), которую мы ранее обсуждали, чтобы определить, какой ход выполнить, основываясь, на максимальном увеличении счета. В этом случае ИИ может автоматически играть в игру 2048, делая оптимальные ходы.

Таким образом, нам понадобится метод выполнения хода ИИ, возможность запуска его автоматически с определенной периодичностью, и, переключатель для переключения между ручным режимом со swipe жестом и воспроизведением ИИ.

Но давайте сначала поймем, какие в SwiftUI есть средства запуска определенный код автоматически через равные промежутки времени:

e2cd9a4f78c043aeffcfceeb13b8149d.png00898818c3d6f55cc85e51ad61d03a2a.pngView, обновляющее счетчик каждую секунду
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()
 }
fbfa26d3f73599c086b0ec8702b4d540.png5d150b42b710a0877a3bc201fd756a67.png
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)
            }
        }
    }
}
2bf7e9914e1db74e7b2efbed889ed451.png

Использование модификатора .onReceive (timer) и Timer.publish в GameView

63bbc74c127649c8ef6f6565e7c78503.png763378fef47cd50aaf7ea67664babd06.pngac1a494bfc415d86cd4988100f0755e0.pngкод GameView c Timer.publish и .omReceive
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()
            }
        }
    }
}
95b0f33fd4d45fab3500d7e6235d9b04.png43ba7d1c998777008b2f2566153c2a94.png
c39ceded2a601cc0a1944b12074b85f3.pngСкрытый текст
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)
    }
}
afd9e93fe6507a3200976b6b8e922a3c.png

A вот наш UI:

6f322c92a4eb4a8a845cef9e69140bb8.gif

ШАГ 11. Лучшая ИИ (AI) стратегия

55c020dfe946dcb9a0c349ce54a58515.png1168a0259526b23f2be3b04f6ff796f3.png113da4aff09c5db95e82bc205e448dfa.pngfe6e462e51e77c0bf9294793cd5254fa.png4ccebf6ca3d9d8eb1cdde0217a7344e9.png02561f2e3e1a6758e1c659a63005b2ce.png

ШАГ 12. Алгоритм Expectimax

de25203e33a95a551e7e732ef509da6c.pngbecef33a9d930c0981eabc0cdc8182c8.png
enum Direction: CaseIterable {
    case up, down, left, right
}
4d403a7d6c647126d341f305faa0e358.png
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
}
a15035da47d3f233fb76611542f79508.pngкод алгоритмв expectimax:
func expectimax(board: [[Tile]], depth: Int, isAITurn: Bool) -> Double {
      // Base case: return the board evaluation if depth is 0 or game is over
        if depth == 0 || isGameOver(board) {
            return evaluateBoard  (board)
        }
        
        // AI's move (maximize the score)
        if isAITurn {
            var maxScore = -Double.infinity
            for direction in Direction.allCases {
                let newBoard = GameViewModel (matrix: board)
                let (moved, _) = newBoard.slide(direction)
                if moved {
                 // Recur for the next move, but now it's the tile placement's turn
                    maxScore = max(maxScore, 
        expectimax(board: newBoard.tiles, depth: depth - 1, isAITurn: false))
                }
            }
            return maxScore
        }
        // Random tile placement's move (chance node)
        else {
            var expectedScore = 0.0
            let emptyTiles = board.flatMap{$0}.filter{$0.value == 0}
            // If no empty tiles, the game is over
            if emptyTiles.isEmpty {
                return evaluateBoard (board)
            }
            
            // For each empty tile, calculate the expected value
            for tile in emptyTiles {
                var boardWith2 = board
                boardWith2[tile.position.row][tile.position.col].value = 2
                var boardWith4 = board
                boardWith4[tile.position.row][tile.position.col].value = 4
                
                // 90% probability of placing a '2' tile, 10% of placing a '4' tile
                expectedScore += 
        0.9 * expectimax(board: boardWith2, depth: depth - 1, isAITurn: true)
                expectedScore += 
        0.1 * expectimax(board: boardWith4, depth: depth - 1, isAITurn: true)
            }
            return expectedScore / Double(emptyTiles.count)
        }
    }

  func evaluateBoard(_ board: [[Tile]]) -> Double {
        let monotonicityWeight = 1.0
        let smoothnessWeight = 0.1
        let emptyTilesWeight = 2.7
        let maxTileWeight = 1.0

        let emptyTilesCount = 
               Double(board.flatMap{$0}.filter{$0.value == 0}.count)
              
        return monotonicity(board) * monotonicityWeight +
               smoothness(board) * smoothnessWeight +
               emptyTilesCount * emptyTilesWeight +
               maxTileInCorne() * maxTileWeight
    }
    
    func  monotonicity (_ board: [[Tile]]) -> Double {
        // calculate
        return 0.0
    }
    func  smoothness (_ board: [[Tile]]) -> Double {
        // calculate
        return 0.0
    }

    func maxTileInCorner(_ board: [[Tile]]) -> Double 
        // calculate
        return 0.0
    }
9d9e7c2f8b3ad9b15d99aec9a41913cf.png71c61f9429c57623defd3038f55543f6.png8f3d10dbdc560f5a17d48dc3688ff7ac.png890d15eb5324e80c75d771f4884e9ef6.pngfc15972e558226300789a17445d118c5.pngкод функции expectimaxBestMove (
// MARK: - Expectimax
    func expectimaxBestMove (depth: Int, matr [[Tile]]) -> Direction {
        var bestDirection = Direction.right
        var bestScore: Double = -Double.infinity
 
       // for move in possibleMoves {
        for direction in Direction.allCases {
            var model = GameViewModel (matrix: matrix) // Initialize Game
            let (moved, _ ) = model.slide(direction)
            if moved {
               let newScore = 
          expectimaxScore (board: model.tiles, depth: depth, isAITurn: false)
               if newScore > bestScore {
                    bestScore = newScore
                    bestDirection = direction
                }
            }
        }
        return bestDirection
    }
df21eeabc541754e0f800242c539d3c7.pngкод GameViewModel
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...
}
7806ad5edd14bb19429c689549e6cd49.pngGameView
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 поиск оптимального хода:

7d55d3dd16f4ce3efe83e03f3bb31844.gif
cfbb245f2c00363e579eeaf849def158.gif

ШАГ 13. Улучшение функции evaluate()

cb4c24fbd89366fe3de1f443c8857d8c.pnge86bd41da9bdc7ee5d8f2bf25f0b2d74.pngфункция monotonicity (grid: )
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)
    }
c1332c6d2aaeaa8b3dd5e15834bb7287.pngфункция smoothness (grid: )
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
  }
6ef3f513a601afa620e842e296803eb9.pngфункция func emptyTileCount(board: )
func emptyTileCount(_ board: [[Tile]]) -> Double {
    return Double(board.flatMap { $0 }.filter { $0.value == 0 }.count)
}
13d3b132bb0d47930786f36235c29a81.pngфункция maxTileInCorner(board: ) -> Double
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()

72c151b32b78211b981b040cb3cd7d94.pngфункция evaluate(board:)
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
}
d7bad29ca52124367d11346dcf4dd792.pngfbb3fba1ef6522389bdce377adafce97.png

ШАГ 14. Эвристика в виде Snake (Змея) паттерна

Два способа организации игровой доски в виде Snake паттерна показаны на рисунке:

54cfbf943203aba5f29cd9f2784200aa.png
Матрица весов для Snake паттерна игры 2048
Матрица весов для Snake паттерна игры 2048
e6689f827531ce8cc7eb5561d5403ece.png20aa50fabf7f37bbd0befe753d4c74bd.png2454b365bc70a19c8f5656615c72f4db.png00d2f6c21f0677249751484ebcaaa764.png
[15, 14, 13, 12]
[8,  9,  10, 11]
[7,  6,  5,  4]
[0,  1,  2,  3]
43ba2eb49b5d792c29ed69e0e966fb52.pngфункция snakeHeuristic(board:)
func snakeHeuristic(_ board: [[Tile]]) -> Double {
    // Snake pattern score weights for each tile position
    let snakePattern: [[Double]] = [
        [15, 14, 13, 12],
        [8,  9,  10, 11],
        [7,  6,  5,  4],
        [0,  1,  2,  3]
    ]
    
    var score = 0.0

    // Evaluate how well the board follows the snake pattern
    for row in 0..<4 {
        for col in 0..<4 {
            let tileValue = board[row][col].value
            if tileValue > 0 {
            score += Double(log2(Double(tileValue))) * snakePattern[row][col]
            }
        }
    }

    return score
}
5f73df48e0f5d412c91ed13701421489.pngфункция evaluateBoard ( board: )
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)
     }
136b15b95934ead6a16d1e18865761b2.pngc4b053ac19ee7d9b17947cdbee3856b1.png
[2^15, 2^14, 2^13, 2^12]
[2^8,  2^9,  2^10, 2^11]
[2^7,  2^6,  2^5,  2^4]
[2^0,  2^1,  2^2,  2^3]
функция snakeHeuristic(_ board: )
func snakeHeuristic(_ board: [[Tile]]) -> Double {
    // Snake pattern score weights for each tile position based on powers of 2
    let snakePattern: [[Double]] = [
        [pow(2, 15), pow(2, 14), pow(2, 13), pow(2, 12)],
        [pow(2, 8),  pow(2, 9),  pow(2, 10), pow(2, 11)],
        [pow(2, 7),  pow(2, 6),  pow(2, 5),  pow(2, 4)],
        [pow(2, 0),  pow(2, 1),  pow(2, 2),  pow(2, 3)]
    ]
    
    var score = 0.0

    // Evaluate how well the board follows the snake pattern
    for row in 0..<4 {
        for col in 0..<4 {
            let tileValue = board[row][col].value
                score += Double(tileValue) * snakePattern[row][col]
        }
    }

    return score
a5188116f07872bdfb113a1a7ea05277.pngba5c6330da0dc2d5b523d8e085f07ec4.png

ШАГ 15. Метод Monte Carlo как ИИ для игры 2048

c0b2868870cd8def51f58c5c999f75d8.png19c259f8b197bf5980e7dfccc3e93a3d.pngc5f987692a66a8597a580edaeffc8b5e.pngфункция monteCarloSearch (board: simulations: depth: )
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
    }
d4a020e97b1348e33efe383c7a9025a7.pngфункция randomGame(board: depth:)
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)
    }
97a0070a7b5f56903a294a90c9415056.pngфункция evaluateBoard( board: )
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.
}
5bb1fae10f5064f3baf56382b283c45c.pngf22db617168e050acccaf117fbb2dfc8.png7dae3a63788939ff9e5ca058f0d8e442.png

ШАГ 16. Усовершенствование Monte Carlo как ИИ для игры 2048

725fe7792fd58bcaa177d29dedc92f72.png1d0d338ff6842878c7c2efa57d9c2b6e.pngcd9cf8669f32ef8c5b1e9884b1aa5b30.pngкод biasedRandomGame(direction: board:depth: Int)
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
    }
22c2370e74e99d080948bc61c16fb1ea.pngкод randomGameWithEarlyStopping(board: depth: maxBadMoves:)
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)
2c1429679b43784c204d2eb2a90758d3.pngкод monteCarloSearchWithDynamicSimulations(board: maxSimulations: depth:
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
}
9f147c4603d8b2e5f117f6a688613afc.pngкод runSimulationsParallel(board: direction: simulations: depth: )
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)
}
c55b98a4104c968c93ce2df12edc38c9.pngкод evaluateBoard(board: )
func evaluateBoard(_ board: [[Tile]]) -> Double {
    let smoothness = calculateSmoothness(board)
    let monotonicity = calculateMonotonicity(board)
    let emptySpaces = Double(getEmptyTiles(board).count)
    
    return smoothness + monotonicity + emptySpaces * 2
}
e1ee296dddee0bc8796570a2d4a24dd8.png4240a08f4b0050d95246b65612bced60.png

Давайте выберем параллельное симулирование и просим ChatGPT реализовать предложенную ранее функцию runSimulationsParallel(board: direction: simulations: depth: ) с помощью более современных async await:

ШАГ 17. Сделай рефакторинг runSimulationsParallel с async await

90fad8314f61968f587b4ec342891bdb.png065f819112cd08e49339c2d6843b4d33.png416233a6724af0ef7d2182beb9dfa91c.png
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)
}
d351518fb841915deb06c140aff12bfd.png443ee0e2e273e2b1e6106df03a1c82a2.png

ШАГ 18. Параллелизм expectimax с async await

398afddd4649c01ad0dba632ea0fc45f.png6713a5e9eb24a18d079f0f7ec14ad75a.pngfd769f0f510a7ad9b8b75eb2ef1c75bc.pngкод expectimaxAsyn (grid: depth: isAITurn)
 // 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)
                }
            }
        }
    }
1628e6f462981789a79d581d1670bb4b.pngf2b1c6c6ce5ebc87e64fbbb588d93d4d.png
// 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
    }
e1250fb7254edc74fc622c4ec1e9ce37.png
 func bestMoveDirectionExpectimaxAsync() async -> Direction {
    let direction = await aiGame.bestExpectimaxAsync(depth: 5, matrix: tiles)
        return direction
  }
5e15220e4f56f3d5c7235f695abd2186.png
func expectimaxAsyncAIMove() {
        Task{
            let bestDirection =  await game.bestMoveDirectionExpectimaxAsync()
            game.move(bestDirection)
         } 
 }
d32c3089016126b3c28caec4c248a77f.png
.onReceive(timer){ value in
          if isAIPlaying  && !viewModel.isGameOver {
              if selectedAlgorithm == Algorithm.MonteCarloAsync {
                  viewModel.monteCarloAsyncAIMove()
              } else if selectedAlgorithm == Algorithm.Expectimax1 {
                  viewModel.expectimaxAsyncAIMove()
              } else {
                    viewModel.executeAIMove()
              }
           }
   }
445206316a0000d9db4c51b9e90a1298.png

Заключение:

Благодаря ChatGPT разработка iOS приложений стала более осмысленной. Не нужно отвлекаться на очевидные вещи типа создание кнопки или меню на UI — а сфокусироваться на высокоуровневых концепциях. То есть на самом интересном и важном. Это рождает желание попробовать что-то более рискованное и, возможно, более эффективное, не прикладывая при этом никаких дополнительных усилий. Иными словами просыпается чувство азарта и от программирования с ChatGPT получаешь истинное удовольствие.

Что же понравилось больше всего?

  1. ChatGPT сразу предлагает полную архитектуру вашего приложения с “заглушками” для конкретных методов и вычисляемых переменных, но которую вы можете дальше успешно развивать, ссылаясь на эти заглушки без дополнительных разъяснений.

  2. ChatGPT предлагает очень содержательные идентификаторы для переменных var, констант let и названий функций func, что существенно облегчает чтение кода и избавляет вас от того, чтобы “ломать голову” над этим. И вы также можете ссылаться на них в последующем диалоге с ChatGPT.

  3. ChatGPT 4-o в совершенстве владеет функциями высшего порядка для работы с коллекциями (map, flatMap, compactMap, filter, allSatisfy) в Swift и всюду предлагает их, иногда в самых неожиданных ситуациях и самым изобретательным образом, что приятно удивляет.

  4. Прекрасно владеет архитектурой MVVM (возможно, и другими, просто не пробовала), предлагая как незащищенную модель, когда ViewModel и Model в одном классе (с протоколом ObservableObject или новым макросом @Observable), так и классическую защищенную модель: Model отдельно от ViewModel и View. Легко переходит от одной к другой.

  5. Расшифровывает все ошибки и даёт дельные советы по их исправлению.

  6. В большинстве случаев запоминает и хранит наработанный в процессе взаимодействия код для поставленной задачи на протяжении почти всей сессии и позволяет ссылаться на различные его этапы.

  7. Хорошо рефакторит код.

  8. Генерирует Unit тесты с использованием XCTest.

  9. Проявляет фантастическую эрудицию в части ИИ алгоритмов для игр типа 2048.

И много чего еще ….

Все свои предложения кода ChatGPT сопровождает такими подробными объяснениями, которые не даст вам ни один курс обучения. Так что параллельно идет очень интенсивное обучение языку программирования Swift и фреймворку SwiftUI (мне это вроде как не требовалось, но все равно всякий раз открывала что-то новое!!!). Если вы изучаете программирование на Swift и SwiftUI, попробуйте самостоятельно пройти мой путь. Вы получите колоссальный опыт разработки iOS приложений.

Недостатки:

  • Хотя держит контекст решаемой задачи в процессе одной сессии, код полного приложения приходится собирать по кусочкам, это вам не Claude 3.5 Sonnet. Однако к настоящему моменту появился новый способ взаимодействия - ChatGPT 4 Canvas, который полностью держит разрабатываемый проект, но я его еще не пробовала.

  • Иногда "увиливает" от прямо поставленного вопроса.

  • Часто даёт код предыдущей версии: протокол ObservableObject вместо макроса @Observable,GCD (Grand Central Dispatch) вместо async await, но стоит на это указать и ChatGPT великолепно выполняет рефакторинг кода и объясняет различие между новым синтаксисом и старым.

При работе над iOS приложением игры 2048 с помощью chatGPT мне ни разу не пришлось обращаться к Google или StackOverFlow, так что ChatGPT вполне может заменить эти два инструмента при разработке iOS приложений.

Источник

  • 07.09.23 16:24 CherryTeam

    Cherry Team atlyginimų skaičiavimo programa yra labai naudingas įrankis įmonėms, kai reikia efektyviai valdyti ir skaičiuoti darbuotojų atlyginimus. Ši programinė įranga, turinti išsamias funkcijas ir patogią naudotojo sąsają, suteikia daug privalumų, kurie padeda supaprastinti darbo užmokesčio skaičiavimo procesus ir pagerinti finansų valdymą. Štai keletas pagrindinių priežasčių, kodėl Cherry Team atlyginimų skaičiavimo programa yra naudinga įmonėms: Automatizuoti ir tikslūs skaičiavimai: Atlyginimų skaičiavimai rankiniu būdu gali būti klaidingi ir reikalauti daug laiko. Programinė įranga Cherry Team automatizuoja visą atlyginimų skaičiavimo procesą, todėl nebereikia atlikti skaičiavimų rankiniu būdu ir sumažėja klaidų rizika. Tiksliai apskaičiuodama atlyginimus, įskaitant tokius veiksnius, kaip pagrindinis atlyginimas, viršvalandžiai, premijos, išskaitos ir mokesčiai, programa užtikrina tikslius ir be klaidų darbo užmokesčio skaičiavimo rezultatus. Sutaupoma laiko ir išlaidų: Darbo užmokesčio valdymas gali būti daug darbo jėgos reikalaujanti užduotis, reikalaujanti daug laiko ir išteklių. Programa Cherry Team supaprastina ir pagreitina darbo užmokesčio skaičiavimo procesą, nes automatizuoja skaičiavimus, generuoja darbo užmokesčio žiniaraščius ir tvarko išskaičiuojamus mokesčius. Šis automatizavimas padeda įmonėms sutaupyti daug laiko ir pastangų, todėl žmogiškųjų išteklių ir finansų komandos gali sutelkti dėmesį į strategiškai svarbesnę veiklą. Be to, racionalizuodamos darbo užmokesčio operacijas, įmonės gali sumažinti administracines išlaidas, susijusias su rankiniu darbo užmokesčio tvarkymu. Mokesčių ir darbo teisės aktų laikymasis: Įmonėms labai svarbu laikytis mokesčių ir darbo teisės aktų, kad išvengtų baudų ir teisinių problemų. Programinė įranga Cherry Team seka besikeičiančius mokesčių įstatymus ir darbo reglamentus, užtikrindama tikslius skaičiavimus ir teisinių reikalavimų laikymąsi. Programa gali dirbti su sudėtingais mokesčių scenarijais, pavyzdžiui, keliomis mokesčių grupėmis ir įvairių rūšių atskaitymais, todėl užtikrina atitiktį reikalavimams ir kartu sumažina klaidų riziką. Ataskaitų rengimas ir analizė: Programa Cherry Team siūlo patikimas ataskaitų teikimo ir analizės galimybes, suteikiančias įmonėms vertingų įžvalgų apie darbo užmokesčio duomenis. Ji gali generuoti ataskaitas apie įvairius aspektus, pavyzdžiui, darbo užmokesčio paskirstymą, išskaičiuojamus mokesčius ir darbo sąnaudas. Šios ataskaitos leidžia įmonėms analizuoti darbo užmokesčio tendencijas, nustatyti tobulintinas sritis ir priimti pagrįstus finansinius sprendimus. Pasinaudodamos duomenimis pagrįstomis įžvalgomis, įmonės gali optimizuoti savo darbo užmokesčio strategijas ir veiksmingai kontroliuoti išlaidas. Integracija su kitomis sistemomis: Cherry Team programinė įranga dažnai sklandžiai integruojama su kitomis personalo ir apskaitos sistemomis. Tokia integracija leidžia automatiškai perkelti atitinkamus duomenis, pavyzdžiui, informaciją apie darbuotojus ir finansinius įrašus, todėl nebereikia dubliuoti duomenų. Supaprastintas duomenų srautas tarp sistemų padidina bendrą efektyvumą ir sumažina duomenų klaidų ar neatitikimų riziką. Cherry Team atlyginimų apskaičiavimo programa įmonėms teikia didelę naudą - automatiniai ir tikslūs skaičiavimai, laiko ir sąnaudų taupymas, atitiktis mokesčių ir darbo teisės aktų reikalavimams, ataskaitų teikimo ir analizės galimybės bei integracija su kitomis sistemomis. Naudodamos šią programinę įrangą įmonės gali supaprastinti darbo užmokesčio skaičiavimo procesus, užtikrinti tikslumą ir atitiktį reikalavimams, padidinti darbuotojų pasitenkinimą ir gauti vertingų įžvalgų apie savo finansinius duomenis. Programa Cherry Team pasirodo esanti nepakeičiamas įrankis įmonėms, siekiančioms efektyviai ir veiksmingai valdyti darbo užmokestį. https://cherryteam.lt/lt/

  • 08.10.23 01:30 davec8080

    The "Shibarium for this confirmed rug pull is a BEP-20 project not related at all to Shibarium, SHIB, BONE or LEASH. The Plot Thickens. Someone posted the actual transactions!!!! https://bscscan.com/tx/0xa846ea0367c89c3f0bbfcc221cceea4c90d8f56ead2eb479d4cee41c75e02c97 It seems the article is true!!!! And it's also FUD. Let me explain. Check this link: https://bscscan.com/token/0x5a752c9fe3520522ea88f37a41c3ddd97c022c2f So there really is a "Shibarium" token. And somebody did a rug pull with it. CONFIRMED. But the "Shibarium" token for this confirmed rug pull is a BEP-20 project not related at all to Shibarium, SHIB, BONE or LEASH.

  • 24.06.24 04:31 tashandiarisha

    Web-site. https://trustgeekshackexpert.com/ Tele-Gram, trustgeekshackexpert During the pandemic, I ventured into the world of cryptocurrency trading. My father loaned me $10,000, which I used to purchase my first bitcoins. With diligent research and some luck, I managed to grow my investment to over $350,000 in just a couple of years. I was thrilled with my success, but my excitement was short-lived when I decided to switch brokers and inadvertently fell victim to a phishing attack. While creating a new account, I received what seemed like a legitimate email requesting verification. Without second-guessing, I provided my information, only to realize later that I had lost access to my email and cryptocurrency wallets. Panic set in as I watched my hard-earned assets disappear before my eyes. Desperate to recover my funds, I scoured the internet for solutions. That's when I stumbled upon the Trust Geeks Hack Expert on the Internet. The service claimed to specialize in recovering lost crypto assets, and I decided to take a chance. Upon contacting them, the team swung into action immediately. They guided me through the entire recovery process with professionalism and efficiency. The advantages of using the Trust Geeks Hack Expert Tool became apparent from the start. Their team was knowledgeable and empathetic, understanding the urgency and stress of my situation. They employed advanced security measures to ensure my information was handled safely and securely. One of the key benefits of the Trust Geeks Hack Expert Tool was its user-friendly interface, which made a complex process much more manageable for someone like me, who isn't particularly tech-savvy. They also offered 24/7 support, so I never felt alone during recovery. Their transparent communication and regular updates kept me informed and reassured throughout. The Trust Geeks Hack Expert Tool is the best solution for anyone facing similar issues. Their swift response, expertise, and customer-centric approach set them apart from other recovery services. Thanks to their efforts, I regained access to my accounts and my substantial crypto assets. The experience taught me a valuable lesson about online security and showed me the incredible potential of the Trust Geeks Hack Expert Tool. Email:: trustgeekshackexpert{@}fastservice{.}com WhatsApp  + 1.7.1.9.4.9.2.2.6.9.3

  • 26.06.24 18:46 Jacobethannn098

    LEGAL RECOUP FOR CRYPTO THEFT BY ADRIAN LAMO HACKER

  • 26.06.24 18:46 Jacobethannn098

    Reach Out To Adrian Lamo Hacker via email: [email protected] / WhatsApp: ‪+1 (909) 739‑0269‬ Adrian Lamo Hacker is a formidable force in the realm of cybersecurity, offering a comprehensive suite of services designed to protect individuals and organizations from the pervasive threat of digital scams and fraud. With an impressive track record of recovering over $950 million, including substantial sums from high-profile scams such as a $600 million fake investment platform and a $1.5 million romance scam, Adrian Lamo Hacker has established itself as a leader in the field. One of the key strengths of Adrian Lamo Hacker lies in its unparalleled expertise in scam detection. The company leverages cutting-edge methodologies to defend against a wide range of digital threats, including phishing emails, fraudulent websites, and deceitful schemes. This proactive approach to identifying and neutralizing potential scams is crucial in an increasingly complex and interconnected digital landscape. Adrian Lamo Hacker's tailored risk assessments serve as a powerful tool for fortifying cybersecurity. By identifying vulnerabilities and potential points of exploitation, the company empowers its clients to take proactive measures to strengthen their digital defenses. This personalized approach to risk assessment ensures that each client receives targeted and effective protection against cyber threats. In the event of a security incident, Adrian Lamo Hacker's rapid incident response capabilities come into play. The company's vigilant monitoring and swift mitigation strategies ensure that any potential breaches or scams are addressed in real-time, minimizing the impact on its clients' digital assets and reputation. This proactive stance towards incident response is essential in an era where cyber threats can materialize with alarming speed and sophistication. In addition to its robust defense and incident response capabilities, Adrian Lamo Hacker is committed to empowering its clients to recognize and thwart common scam tactics. By fostering enlightenment in the digital realm, the company goes beyond simply safeguarding its clients; it equips them with the knowledge and awareness needed to navigate the digital landscape with confidence and resilience. Adrian Lamo Hacker services extend to genuine hacking, offering an additional layer of protection for its clients. This may include ethical hacking or penetration testing, which can help identify and address security vulnerabilities before malicious actors have the chance to exploit them. By offering genuine hacking services, Adrian Lamo Hacker demonstrates its commitment to providing holistic cybersecurity solutions that address both defensive and offensive aspects of digital protection. Adrian Lamo Hacker stands out as a premier provider of cybersecurity services, offering unparalleled expertise in scam detection, rapid incident response, tailored risk assessments, and genuine hacking capabilities. With a proven track record of recovering significant sums from various scams, the company has earned a reputation for excellence in combating digital fraud. Through its proactive and empowering approach, Adrian Lamo Hacker is a true ally for individuals and organizations seeking to navigate the digital realm with confidence.

  • 04.07.24 04:49 ZionNaomi

    For over twenty years, I've dedicated myself to the dynamic world of marketing, constantly seeking innovative strategies to elevate brand visibility in an ever-evolving landscape. So when the meteoric rise of Bitcoin captured my attention as a potential avenue for investment diversification, I seized the opportunity, allocating $20,000 to the digital currency. Witnessing my investment burgeon to an impressive $70,000 over time instilled in me a sense of financial promise and stability.However, amidst the euphoria of financial growth, a sudden and unforeseen oversight brought me crashing back to reality during a critical business trip—I had misplaced my hardware wallet. The realization that I had lost access to the cornerstone of my financial security struck me with profound dismay. Desperate for a solution, I turned to the expertise of Daniel Meuli Web Recovery.Their response was swift . With meticulous precision, they embarked on the intricate process of retracing the elusive path of my lost funds. Through their unwavering dedication, they managed to recover a substantial portion of my investment, offering a glimmer of hope amidst the shadows of uncertainty. The support provided by Daniel Meuli Web Recovery extended beyond mere financial restitution. Recognizing the imperative of fortifying against future vulnerabilities, they generously shared invaluable insights on securing digital assets. Their guidance encompassed crucial aspects such as implementing hardware wallet backups and fortifying security protocols, equipping me with recovered funds and newfound knowledge to navigate the digital landscape securely.In retrospect, this experience served as a poignant reminder of the critical importance of diligence and preparedness in safeguarding one's assets. Thanks to the expertise and unwavering support extended by Daniel Meuli Web Recovery, I emerged from the ordeal with renewed resilience and vigilance. Empowered by their guidance and fortified by enhanced security measures, I now approach the future with unwavering confidence.The heights of financial promise to the depths of loss and back again has been a humbling one, underscoring the volatility and unpredictability inherent in the digital realm. Yet, through adversity, I have emerged stronger, armed with a newfound appreciation for the importance of diligence, preparedness, and the invaluable support of experts like Daniel Meuli Web Recovery.As I persist in traversing the digital landscape, I do so with a judicious blend of vigilance and fortitude, cognizant that with adequate safeguards and the backing of reliable confidants, I possess the fortitude to withstand any adversity that may arise. For this, I remain eternally appreciative. Email Danielmeuliweberecovery @ email . c om WhatsApp + 393 512 013 528

  • 13.07.24 21:13 michaelharrell825

    In 2020, amidst the economic fallout of the pandemic, I found myself unexpectedly unemployed and turned to Forex trading in hopes of stabilizing my finances. Like many, I was drawn in by the promise of quick returns offered by various Forex robots, signals, and trading advisers. However, most of these products turned out to be disappointing, with claims that were far from reality. Looking back, I realize I should have been more cautious, but the allure of financial security clouded my judgment during those uncertain times. Amidst these disappointments, Profit Forex emerged as a standout. Not only did they provide reliable service, but they also delivered tangible results—a rarity in an industry often plagued by exaggerated claims. The positive reviews from other users validated my own experience, highlighting their commitment to delivering genuine outcomes and emphasizing sound financial practices. My journey with Profit Forex led to a net profit of $11,500, a significant achievement given the challenges I faced. However, my optimism was short-lived when I encountered obstacles trying to withdraw funds from my trading account. Despite repeated attempts, I found myself unable to access my money, leaving me frustrated and uncertain about my financial future. Fortunately, my fortunes changed when I discovered PRO WIZARD GIlBERT RECOVERY. Their reputation for recovering funds from fraudulent schemes gave me hope in reclaiming what was rightfully mine. With a mixture of desperation and cautious optimism, I reached out to them for assistance. PRO WIZARD GIlBERT RECOVERY impressed me from the start with their professionalism and deep understanding of financial disputes. They took a methodical approach, using advanced techniques to track down the scammers responsible for withholding my funds. Throughout the process, their communication was clear and reassuring, providing much-needed support during a stressful period. Thanks to PRO WIZARD GIlBERT RECOVERY's expertise and unwavering dedication, I finally achieved a resolution to my ordeal. They successfully traced and retrieved my funds, restoring a sense of justice and relief. Their intervention not only recovered my money but also renewed my faith in ethical financial services. Reflecting on my experience, I've learned invaluable lessons about the importance of due diligence and discernment in navigating the Forex market. While setbacks are inevitable, partnering with reputable recovery specialists like PRO WIZARD GIlBERT RECOVERY can make a profound difference. Their integrity and effectiveness have left an indelible mark on me, guiding my future decisions and reinforcing the value of trustworthy partnerships in achieving financial goals. I wholeheartedly recommend PRO WIZARD GIlBERT RECOVERY to anyone grappling with financial fraud or disputes. Their expertise and commitment to client satisfaction are unparalleled, offering a beacon of hope in challenging times. Thank you, PRO WIZARD GIlBERT RECOVERY, for your invaluable assistance in reclaiming what was rightfully mine. Your service not only recovered my funds but also restored my confidence in navigating the complexities of financial markets with greater caution and awareness. Email: prowizardgilbertrecovery(@)engineer.com Homepage: https://prowizardgilbertrecovery.xyz WhatsApp: +1 (516) 347‑9592

  • 17.07.24 02:26 thompsonrickey

    In the vast and often treacherous realm of online investments, I was entangled in a web of deceit that cost me nearly  $45,000. It all started innocuously enough with an enticing Instagram profile promising lucrative returns through cryptocurrency investment. Initially, everything seemed promising—communications were smooth, and assurances were plentiful. However, as time passed, my optimism turned to suspicion. Withdrawal requests were met with delays and excuses. The once-responsive "investor" vanished into thin air, leaving me stranded with dwindling hopes and a sinking feeling in my gut. It became painfully clear that I had been duped by a sophisticated scheme designed to exploit trust and naivety. Desperate to recover my funds, I turned to online forums where I discovered numerous testimonials advocating for Muyern Trust Hacker. With nothing to lose, I contacted them, recounting my ordeal with a mixture of skepticism and hope. Their swift response and professional demeanor immediately reassured me that I had found a lifeline amidst the chaos. Muyern Trust Hacker wasted no time in taking action. They meticulously gathered evidence, navigated legal complexities, and deployed their expertise to expedite recovery. In what felt like a whirlwind of activity, although the passage of time was a blur amidst my anxiety, they achieved the seemingly impossible—my stolen funds were returned. The relief I felt was overwhelming. Muyern Trust Hacker not only restored my financial losses but also restored my faith in justice. Their commitment to integrity and their relentless pursuit of resolution were nothing short of remarkable. They proved themselves as recovery specialists and guardians against digital fraud, offering hope to victims like me who had been ensnared by deception. My gratitude knows no bounds for Muyern Trust Hacker. Reach them at muyerntrusted @ m a i l - m e . c o m AND Tele gram @ muyerntrusthackertech

  • 18.07.24 20:13 austinagastya

    I Testify For iBolt Cyber Hacker Alone - For Crypto Recovery Service I highly suggest iBolt Cyber Hacker to anyone in need of bitcoin recovery services. They successfully recovered my bitcoin from a fake trading scam with speed and efficiency. This crew is trustworthy, They kept me updated throughout the procedure. I thought my bitcoin was gone, I am so grateful for their help, If you find yourself in a similar circumstance, do not hesitate to reach out to iBolt Cyber Hacker for assistance. Thank you, iBOLT, for your amazing customer service! Please be cautious and contact them directly through their website. Email: S u p p o r t @ ibolt cyber hack . com Cont/Whtp + 3. .9 .3. .5..0. .9. 2. 9. .0 .3. 1 .8. Website: h t t p s : / / ibolt cyber hack . com /

  • 27.08.24 12:50 James889900

    All you need is to hire an expert to help you accomplish that. If there’s any need to spy on your partner’s phone. From my experience I lacked evidence to confront my husband on my suspicion on his infidelity, until I came across ETHICALAHCKERS which many commend him of assisting them in their spying mission. So I contacted him and he provided me with access into his phone to view all text messages, call logs, WhatsApp messages and even her location. This evidence helped me move him off my life . I recommend you consult ETHICALHACKERS009 @ gmail.com OR CALL/TEXT ‪+1(716) 318-5536 or whatsapp +14106350697 if you need access to your partner’s phone

  • 27.08.24 13:06 James889900

    All you need is to hire an expert to help you accomplish that. If there’s any need to spy on your partner’s phone. From my experience I lacked evidence to confront my husband on my suspicion on his infidelity, until I came across ETHICALAHCKERS which many commend him of assisting them in their spying mission. So I contacted him and he provided me with access into his phone to view all text messages, call logs, WhatsApp messages and even her location. This evidence helped me move him off my life . I recommend you consult ETHICALHACKERS009 @ gmail.com OR CALL/TEXT ‪+1(716) 318-5536 or whatsapp +14106350697 if you need access to your partner’s phone

  • 02.09.24 20:24 [email protected]

    If You Need Hacker To Recover Your Bitcoin Contact Paradox Recovery Wizard Paradox Recovery Wizard successfully recovered $123,000 worth of Bitcoin for my husband, which he had lost due to a security breach. The process was efficient and secure, with their expert team guiding us through each step. They were able to trace and retrieve the lost cryptocurrency, restoring our peace of mind and financial stability. Their professionalism and expertise were instrumental in recovering our assets, and we are incredibly grateful for their service. Email: support@ paradoxrecoverywizard.com Email: paradox_recovery @cyberservices.com Wep: https://paradoxrecoverywizard.com/ WhatsApp: +39 351 222 3051.

  • 06.09.24 01:35 Celinagarcia

    HOW TO RECOVER MONEY LOST IN BITCOIN/USDT TRADING OR TO CRYPTO INVESTMENT !! Hi all, friends and families. I am writing From Alberton Canada. Last year I tried to invest in cryptocurrency trading in 2023, but lost a significant amount of money to scammers. I was cheated of my money, but thank God, I was referred to Hack Recovery Wizard they are among the best bitcoin recovery specialists on the planet. they helped me get every penny I lost to the scammers back to me with their forensic techniques. and I would like to take this opportunity to advise everyone to avoid making cryptocurrency investments online. If you ​​​​​​have already lost money on forex, cryptocurrency or Ponzi schemes, please contact [email protected] or WhatsApp: +1 (757) 237–1724 at once they can help you get back the crypto you lost to scammers. BEST WISHES. Celina Garcia.

  • 06.09.24 01:44 Celinagarcia

    HOW TO RECOVER MONEY LOST IN BITCOIN/USDT TRADING OR TO CRYPTO INVESTMENT !! Hi all, friends and families. I am writing From Alberton Canada. Last year I tried to invest in cryptocurrency trading in 2023, but lost a significant amount of money to scammers. I was cheated of my money, but thank God, I was referred to Hack Recovery Wizard they are among the best bitcoin recovery specialists on the planet. they helped me get every penny I lost to the scammers back to me with their forensic techniques. and I would like to take this opportunity to advise everyone to avoid making cryptocurrency investments online. If you ​​​​​​have already lost money on forex, cryptocurrency or Ponzi schemes, please contact [email protected] or WhatsApp: +1 (757) 237–1724 at once they can help you get back the crypto you lost to scammers. BEST WISHES. Celina Garcia.

  • 16.09.24 00:10 marcusaustin

    Bitcoin Recovery Services: Restoring Lost Cryptocurrency If you've lost access to your cryptocurrency and unable to make a withdrawal, I highly recommend iBolt Cyber Hacker Bitcoin Recovery Services. Their team is skilled, professional, and efficient in recovering lost Bitcoin. They provide clear communication, maintain high security standards, and work quickly to resolve issues. Facing the stress of lost cryptocurrency, iBolt Cyber Hacker is a trusted service that will help you regain access to your funds securely and reliably. Highly recommended! Email: S u p p o r t @ ibolt cyber hack . com Cont/Whtp + 3. .9 .3. .5..0. .9. 2. 9. .0 .3. 1 .8. Website: h t t p s : / / ibolt cyber hack . com /

  • 16.09.24 00:11 marcusaustin

    Bitcoin Recovery Services: Restoring Lost Cryptocurrency If you've lost access to your cryptocurrency and unable to make a withdrawal, I highly recommend iBolt Cyber Hacker Bitcoin Recovery Services. Their team is skilled, professional, and efficient in recovering lost Bitcoin. They provide clear communication, maintain high security standards, and work quickly to resolve issues. Facing the stress of lost cryptocurrency, iBolt Cyber Hacker is a trusted service that will help you regain access to your funds securely and reliably. Highly recommended! Email: S u p p o r t @ ibolt cyber hack . com Cont/Whtp + 3. .9 .3. .5..0. .9. 2. 9. .0 .3. 1 .8. Website: h t t p s : / / ibolt cyber hack . com /

  • 23.09.24 18:56 matthewshimself

    At first, I was admittedly skeptical about Worldcoin (ref: https://worldcoin.org/blog/worldcoin/this-is-worldcoin-video-explainer-series), particularly around the use of biometric data and the WLD token as a reward mechanism for it. However, after following the project closer, I’ve come to appreciate the broader vision and see the value in the underlying tech behind it. The concept of Proof of Personhood (ref: https://worldcoin.org/blog/worldcoin/proof-of-personhood-what-it-is-why-its-needed) has definitely caught my attention, and does seem like a crucial step towards tackling growing issues like bots, deepfakes, and identity fraud. Sam Altman’s vision is nothing short of ambitious, but I do think he & Alex Blania have the chops to realize it as mainstay in the global economy.

  • 01.10.24 14:54 Sinewclaudia

    I lost about $876k few months ago trading on a fake binary option investment websites. I didn't knew they were fake until I tried to withdraw. Immediately, I realized these guys were fake. I contacted Sinew Claudia world recovery, my friend who has such experience before and was able to recover them, recommended me to contact them. I'm a living testimony of a successful recovery now. You can contact the legitimate recovery company below for help and assistance. [email protected] [email protected] WhatsApp: 6262645164

  • 02.10.24 22:27 Emily Hunter

    Can those who have fallen victim to fraud get their money back? Yes, you might be able to get back what was taken from you if you fell prey to a fraud from an unregulated investing platform or any other scam, but only if you report it to the relevant authorities. With the right plan and supporting documentation, you can get back what you've lost. Most likely, the individuals in control of these unregulated platforms would attempt to convince you that what happened to your money was a sad accident when, in fact, it was a highly skilled heist. You should be aware that there are resources out there to help you if you or someone you know has experienced one of these circumstances. Do a search using (deftrecoup (.) c o m). Do not let the perpetrators of this hoaxes get away with ruining you mentally and financially.

  • 18.10.24 09:34 freidatollerud

    The growth of WIN44 in Brazil is very interesting! If you're looking for more options for online betting and casino games, I recommend checking out Casinos in Brazil. It's a reliable platform that offers a wide variety of games and provides a safe and enjoyable experience for users. It's worth checking out! https://win44.vip

  • 31.10.24 00:13 ytre89

    Can those who have fallen victim to fraud get their money back? Yes, you might be able to get back what was taken from you if you fell prey to a fraud from an unregulated investing platform or any other scam, but only if you report it to the relevant authorities. With the right plan and supporting documentation, you can get back what you've lost. Most likely, the individuals in control of these unregulated platforms would attempt to convince you that what happened to your money was a sad accident when, in fact, it was a highly skilled heist. You should be aware that there are resources out there to help you if you or someone you know has experienced one of these circumstances. Do a search using (deftrecoup (.) c o m). Do not let the perpetrators of this hoaxes get away with ruining you mentally and financially.

  • 02.11.24 14:44 diannamendoza732

    In the world of Bitcoin recovery, Pro Wizard Gilbert truly represents the gold standard. My experience with Gilbert revealed just how exceptional his methods are and why he stands out as the premier authority in this critical field. When I first encountered the complexities of Bitcoin recovery, I was daunted by the technical challenges and potential risks. Gilbert’s approach immediately distinguished itself through its precision and effectiveness. His methods are meticulously designed, combining cutting-edge techniques with an in-depth understanding of the Bitcoin ecosystem. He tackled the recovery process with a level of expertise and thoroughness that was both impressive and reassuring. What sets Gilbert’s methods apart is not just their technical sophistication but also their strategic depth. He conducts a comprehensive analysis of each case, tailoring his approach to address the unique aspects of the situation. This personalized strategy ensures that every recovery effort is optimized for success. Gilbert’s transparent communication throughout the process was invaluable, providing clarity and confidence during each stage of the recovery. The results I achieved with Pro Wizard Gilbert’s methods were remarkable. His gold standard approach not only recovered my Bitcoin but did so with an efficiency and reliability that exceeded my expectations. His deep knowledge, innovative techniques, and unwavering commitment make him the definitive expert in Bitcoin recovery. For anyone seeking a benchmark in Bitcoin recovery solutions, Pro Wizard Gilbert’s methods are the epitome of excellence. His ability to blend technical prowess with strategic insight truly sets him apart in the industry. Call: for help. You may get in touch with them at ; Email: (prowizardgilbertrecovery(@)engineer.com) Telegram ; https://t.me/Pro_Wizard_Gilbert_Recovery Homepage ; https://prowizardgilbertrecovery.info

Для участия в Чате вам необходим бесплатный аккаунт pro-blockchain.com Войти Регистрация
Есть вопросы?
С вами на связи 24/7
Help Icon