Эпиграф: "Не существует модельно независимого подтверждения реальности. Из этого следует, что хорошо сконструированная модель создает свою собственную реальность. Примером того, что может помочь нам понять о реальности и создании, является Игра Жизни, изобретенная в 1970 году молодым математиком из Кембриджа по имени Джон Конвей."
Стивен Хокинг "Высший замысел"
Игра Жизнь (Game of Life) очень интересная и самая известная реализация клеточного автомата. Это не игра в привычном нам смысле, где один игрок сражается с компьютером или с другим игроком. "Жизнь" играет сама по себе, развиваясь из поколения в поколение, подобно нашей Вселенной!
Для игры нам понадобится поле, в идеале бесконечное, разделенное на клетки. Каждая клетка может находиться в одном из двух состояний: живая (будим красить их черным цветом) или мертвая (будем красить белым). Для начала игры нужно расставить на поле живые клетки. Вокруг каждой клетки расположено восемь других клеток. В этой вселенной действуют три простых правила:
Эти три простых правила создают очень сложные и интересные структуры в процессе игры. Некоторые структуры стабильны, некоторые имеют периодически изменяющийся характер, некоторые растут и умирают.
Разработка iOS приложений - дело непростое. Целеообразно начать его с обсуждения общей архитектуры. Давайте при разработке нашего приложения полностью отделим логику от визуализации и интерфейса. Мы создадим объект GameState, который будет отвечать за формирование следующего поколения клеток, решая какая клетка должна выжить, а какая умереть.
Для начала создадим клетку. Модель Cell будет отражать состояние клетки в нашей игре (не путайте с UITableViewCell или UICollectionViewCell в iOS).
Наша клетка знает свое состояние и имеет два метода для перевода в режим живая или мертвая.
struct Cell { var isAlive: Bool = false static func makeDeadCell() -> Cell { return Cell(isAlive: false) } static func makeLiveCell() -> Cell { return Cell(isAlive: true) } }
Кроме того, нам потребуется модель, которая будет хранить состояние всего поля.
struct GameState { var cells: [Cell] = [] subscript(index: Int) -> Cell { get { return cells[index] } } }
Индекс(subscript) gameState[i] позволяет получить прямой доступ к i-ой клетке. Он аналогичен вызову gameState.cells[i] и немного сокращает размер нашего кода.
Нам понадобится функция iterate(), которая будет перебирать ячейки и изменять их состояние
func iterate() -> GameState { var nextState = currentState for i in 0...width - 1 { for j in 0...height - 1 { let positionInTheArray = j*width + i nextState[positionInTheArray] = Cell(isAlive: state(x: i, y: j)) } } self.currentState = nextState return nextState }
Приведенный выше код содержит функция state, которая определяет состояние клетки на основе трех правил игры. Мы напишем эту функцию в следующем разделе, используя подход TDD (Test-driven development).
В этом разделе мы обсудим важность TDD и поговорим о том, как он упрощает разработку.
Я много раз слышал от разработчиков, что написание тестов не приводит к исчезновению всех ошибок, поскольку мы все равно забываем протестировать некоторые крайние случаи. Большинство из них избегают написания тестов, поскольку не видят в них большого смысла.
Написание тестов - это не про решение всех багов в коде - это про написание боле понятного кода, уверенный рефакторинг, избегание регрессии и улучшение читаемости кода за счет изучения тестовых случаев.
Как много раз вы сталкивались со следующим сценарием: Вы ведете большой кусок плохого кода и решаете сделать рефакторинг. Через некоторое время Вы обнаруживаете, что поломали что-то. С грустью вы откатываетесь назад с мыслью: "чтож ... может в следующий раз", поскольку боитесь поломать весь код и зависимости.
Другой причиной, по которой разработчики избегают написания тестов - боязнь того, что у них не будет времени на тесты, что половину времени они убьют на написание тестов и на реальный код у них останется всего половина времени. Этот страх не имеет под собой никаких оснований, и ниже мы докажем это.
Давайте используем подход TDD для реализации наших правил. При этом TDD дает нам следующие преимущества. В любой момент мы можем:
Разработка, основанная на тестировании, часто неправильно понимается. Разработчики делают следующие ошибки
Оба эти подхода неправильны и приводят к ошибкам. После того как вы написали рабочий код, трудно заставить себя вернуться назад и обложить его тестами. Написание всех возможных тестов занимает много времени, и вы рискуете застрять на этом этапе, нервируя своего руководителя долгим отсутствием прогресса.
Правильным является подход, при котором тесты и код пишутся параллельно и улучшают друг-друга. Давайте проиллюстрируем этот подход с помощью техники красный-зеленый-рефакторинг.
Техника красный-зеленый-рефакторинг - отличный подход к параллельному написанию тестов и кода. Она состоит из трех простых шагов:
Для начала давайте напишем тест для первого правила "Живая клетка остается живой в следующем поколении, если вокруг нее находятся две или три живые клетки."
//1. Первое правило про выживание. func test_survival() { let twoAliveNeighboursGameState = GameState(cells: [Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeDeadCell()]) game.setInitialState(twoAliveNeighboursGameState) XCTAssertTrue(game.state(x: 1, y: 1)) let threeAliveNeighboursGameState = GameState(cells: [Cell.makeDeadCell(), Cell.makeLiveCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeDeadCell(), Cell.makeLiveCell(), Cell.makeDeadCell()]) game.setInitialState(threeAliveNeighboursGameState) XCTAssertTrue(game.state(x: 1, y: 1)) }
Если мы запустим этот тест, то он упадет, поскольку наша функция state(…) пока что возвращает значение "false". Ничего страшного, мы сделали первый шаг нашей техники. Теперь давайте напишем код, который сможет пройти этот тест:
func state(x: Int, y: Int) -> Bool { var numberOfAliveNeighbours = 0 let previousPosition = x + y*width for i in x-1…x+1 { for j in y-1…y+1 { let positionInTheArray = j*width + i guard positionInTheArray >= 0 && positionInTheArray < width*height && previousPosition != positionInTheArray else {continue} if currentState[positionInTheArray].isAlive { numberOfAliveNeighbours += 1 } } } let wasPrevioslyAlive = currentState[previousPosition].isAlive if wasPrevioslyAlive { return numberOfAliveNeighbours == 2 || numberOfAliveNeighbours == 3 } return false }
Мы смотрим на всех соседей нашей клетки по x-1…x+1 и y-1…y+1, то есть считаем соседние клетки слева, справа, сверху, снизу и по диагонали.
Теперь все наши тесты успешно проходятся. В любое время мы сможем переписать эту функцию и быть уверенными, что ничего не поломали. Например, мы можем решить использовать функцию reduce вместо циклов for. После внесения правок мы будем уверены, что все по-прежнему работает правильно.
Аналогично мы напишем тесты для двух оставшихся правил. Затем мы опять используем технику красный-зеленый-рефакторинг, чтобы убедиться, что ничего не поломали в процессе.
//2. Второе правило про рождение клетки. func test_birth() { let deadCellState = GameState(cells:[Cell.makeLiveCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeDeadCell() ]) game.setInitialState(deadCellState) XCTAssertTrue(game.state(x: 1, y: 0)) } //3. Третье правило про смерть. func test_death() { let lonelyState = GameState(cells: [Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeLiveCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeDeadCell(), Cell.makeDeadCell()]) game.setInitialState(lonelyState) XCTAssertEqual(false, game.state(x: 1, y: 1)) let overcrowdingState = GameState(cells: [Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeLiveCell(), Cell.makeLiveCell()]) game.setInitialState(overcrowdingState) XCTAssertEqual(false, game.state(x: 1, y: 1)) }
Отлично, у нас есть программная реализация всех трех правил игры Жизнь с тестами. Не важно сколько раз мы решим переписать код, мы всегда сможем убедиться, что правила работают, просто перезапустив тесты.
Часть представления будет создана просто в виде коллекции view, которые будут обновляться по таймеру. Конечно, эта реализация может быть улучшена с помощью какого-нибудь game development frameworks для iOS, но разработка этого iOS приложения - тема другой статьи.
Нам нужно как-то отслеживать состояние нашей игры, поэтому мы собираемся создать своего рода наблюдателя за состоянием. Давайте добавим его в игру
func addStateObserver(_ observer: GameStateObserver) { observer?(generateInitialState()) Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in observer?(self.iterate()) } }
И как только состояние изменится, мы просто перезагрузим данные в нашей коллекции view:
override func viewDidLoad() { super.viewDidLoad() game = Game(width: width, height: height) game.addStateObserver { [weak self] state in self?.display(state) } } func display(_ state: GameState) { self.dataSource = state.cells collectionView.reloadData() }
Конфигурация наших клеток будет выглядеть следующим образом:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "\(SquareCollectionViewCell.self)", for: indexPath) as! SquareCollectionViewCell cell.configureWith(dataSource[indexPath.item].isAlive) return cell }
Мы просто установим цвет клетки в черный или белый.
func configureWith(_ state: Bool) { squareView.backgroundColor = state ? .black : .white }
Ну все, можем пробовать. Если мы запустим игру, то увидим что-то вроде этого:
Маленькая вселенная живет, опираясь на простые три правила. Если вы хотите увидеть завершенный проект, то можете посетить страницу на GitHub: https://github.com/Arrlindii/AAGameOfLife
Если вы будете достаточно долго экспериментировать с игрой Жизнь, то обнаружите много различных структур.
Здесь встречаются статичные структуры, которые могут существовать без изменений бесконечно долгое время. Есть структуры, которые изменяются периодически по определенной схеме и через определенное количество поколений возвращаются в свое исходное состояние.
Существуют также структуры, называемые "планерами". Они периодически изменяются, перемещаясь при этом по диагонали.
Примечательно, что если мы рассматриваем не маленькую область, то видим довольно просты структуры, но если взять площади побольше, то мы увидим и мигающие структуры, и космические корабли, и целые фабрики. В правилах игры не было заложено ни движение клеток, ни их столкновений, однако в этой вселенной существуют закономерности, определяющие движение и столкновение структур.
Если мы определяем живые существа как систему, которая может воспроизводить себя, то в данном случае "жизнь" не стабильна. Но что, если мы можем представить более сложный набор законов, который позволил бы создать сложную систему со всеми атрибутами жизни? Будет ли такой объект осознавать себя?
В этой статье мы реализовали игру "Жизнь", используя Swift для iOS. Мы использовали подход test-driven development для реализации правил игры и показали, как он позволяет улучшить процесс разработки. Также мы вскользь коснулись структур, которые могут возникать на игровом поле.
Конвей хотел знать, может ли вселенная с простыми правилами содержать достаточно сложные объекты, чтобы их можно было воспроизвести. Игра доказала, что да, это возможно, и более того, такие объекты будут, в некотором смысле, интеллектуальными!
Источники: Creating Game of Life on iOS