Статьи

Создайте игру-головоломку тетромино, используя Swift — Геймплей

Благодаря самой простой механике наша игра сможет предложить увлекательный игровой процесс с высокой стоимостью воспроизведения. Посмотрев на анимацию игры во Введении еще раз, мы можем легко идентифицировать большинство из этих правил. Наиболее очевидные из которых включают правила падения, движения и столкновения. У нас также есть правила по очистке линий, подсчету очков и повышению уровня. Давайте реализуем их все теперь, начиная с правила падения.

Отбрасывание тетромино

Мир нашей игры использует свою собственную уникальную гравитацию. Он значительно отличается от типичного имитации гравитации в других играх, который дает плавный, ускоряющий эффект падения. В нашей игре тетромино падают по одному ряду с постоянной скоростью, которая увеличивается только при повышении уровня. Это означает, что мы не можем полагаться на физический движок SpriteKit по умолчанию для симуляции гравитации, поэтому мы должны вручную контролировать и отсчитывать каждую каплю тетромино.

Обновите класс GameScene новыми свойствами и методами в приведенном ниже коде, чтобы включить нашу собственную гравитацию. Оставьте существующий код отмеченным знаком --- .

 --- let defaultSpeed = NSTimeInterval(1200) class GameScene: SKScene { var dropTime = defaultSpeed var lastUpdate:NSDate? override func didMoveToView(view: SKView) { /* Setup your scene here */ self.anchorPoint = CGPoint(x: 0, y: 1.0) lastUpdate = NSDate() } override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {---} override func update(currentTime: CFTimeInterval) { /* Called before each frame is rendered */ if lastUpdate != nil { let elapsed = lastUpdate!.timeIntervalSinceNow * -1000.0 if elapsed > dropTime { moveTetrominoesDown() } } } func drawTetrominoAtPoint(location: CGPoint) {---} func moveTetrominoesDown() { let squares = self.children as [SKSpriteNode] for square in squares { square.position.y += CGFloat(-blockSize) } lastUpdate = NSDate() } } 

Мы обсудим этот код в следующем разделе. Пока что, запустите ( ⌘ + R ) нашу игру и коснитесь любого места на экране симулятора, чтобы нарисовать случайное тетромино, которое мгновенно начнет падать на один ряд каждые 1200 миллисекунд. Вы заметили, как уменьшается количество узлов, когда тетромино выходят за пределы экрана? Вопреки тому, что вы видите, узлы на самом деле все еще находятся в памяти. Все еще часть дерева узлов сцены, живая и потребляющая ценные ресурсы в фоновом режиме. Счетчик узлов показывает только количество видимых узлов на сцене.

Падающие тетромино

Это не совсем то поведение, которое мы хотим для нашей игры. Мы хотим, чтобы тетромино приземлились на платформе, а не выходили за пределы экрана. Обработка столкновений поможет нам достичь этого.

Обнаружение столкновения

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

Следуйте приведенным ниже инструкциям, чтобы обновить наш файл GameScene.swift . Опять же, оставьте существующий код отмеченным знаком --- .

  1. Добавьте новое растровое значение, которое будет представлять игровую область (или игровую доску) со стенами с обеих сторон и полом, на котором будут лежать наши кирпичи.
 import SpriteKit let colors: [SKColor] = [---] let gameBitmapDefault: [[Int]] = [ [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], [8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8] ] 
  1. Добавьте новые данные, которые нам понадобятся для нашего пользовательского чертежа и обнаружения столкновений.
 class GameScene: SKScene { var dropTime = defaultSpeed var lastUpdate:NSDate? let gameBoard = SKSpriteNode() var activeTetromino = Tetromino() var gameBitmapDynamic = gameBitmapDefault var gameBitmapStatic = gameBitmapDefault --- } 

Константа gameBoard будет содержать спрайты, которые будут представлять игровое поле, как определено gameBitmapDefault . Мы используем переменную activeTetromino для представления текущего падающего кирпича. В отличие от предыдущего примера, где мы допускаем падение любого количества кубиков, теперь мы ограничиваем его одним.

Это означает, что нам придется переименовать метод moveTetrominoesDown чтобы просто moveTetrominoDown для согласованности и корректности. Сделай это сейчас, прежде чем забыть .

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

  1. Давайте продолжим и настроим нашу сцену. Обновите метод didMoveToView следующим кодом:
 class GameScene: SKScene { --- override func didMoveToView(view: SKView) { /* Setup your scene here */ self.anchorPoint = CGPoint(x: 0, y: 1.0) gameBoard.anchorPoint = CGPoint(x: 0, y: 1.0) for col in 0..<gameBitmapDefault[0].count { for row in 0..<gameBitmapDefault.count { let bit = gameBitmapDefault[row][col] let square = SKSpriteNode(color: colors[bit], size: CGSize(width: blockSize, height: blockSize)) square.anchorPoint = CGPoint(x: 0, y: 0) square.position = CGPoint(x: col * Int(blockSize) + col, y: -row * Int(blockSize) + -row) gameBoard.addChild(square) } } let gameBoardFrame = gameBoard.calculateAccumulatedFrame() gameBoard.position = CGPoint(x: CGRectGetMidX(self.frame) - gameBoardFrame.width / 2, y: -125) self.addChild(gameBoard) centerActiveTetromino() refresh() lastUpdate = NSDate() } --- } 

После установки точки привязки сцены в верхнем левом углу мы рисуем игровое поле, анализируя целые числа, хранящиеся в константе gameBitmapDefault . Опорная точка игрового поля была также установлена ​​в верхнем левом углу для удобства. Мы уже видели это for in циклическом коде ранее в разделе рисования тетромино. Теперь мы применяем ту же технику, чтобы нарисовать всю игровую доску.

После создания спрайтов для представления каждого блока игрового поля мы извлекаем фрейм игрового поля, вызывая его метод CalculateAccumulatedFrame. Мы не можем полагаться на свойство frame игрового поля, потому что оно просто вернет кадр с нулевыми размерами. Нам нужно получить совокупную ширину и высоту всех дочерних узлов игрового поля, и единственный способ добиться этого — вычислить его с помощью метода CalculateAccumulatedFrame. Получив это значение, мы теперь можем центрировать игровое поле горизонтально на сцене. Наконец, чтобы показать игровую доску, нам нужно добавить ее в качестве дочернего узла текущей сцены.

Теперь мы устанавливаем позицию активного тетромино на середину игрового поля, вызывая пользовательский метод с именем centerActiveTetromino . Добавьте этот метод сразу после метода didMoveToView :

 class GameScene: SKScene { --- override func didMoveToView(view: SKView) {---} func centerActiveTetromino() { let cols = gameBitmapDefault[0].count let brickWidth = activeTetromino.bitmap[0].count activeTetromino.position = (cols / 2 - brickWidth, 0) } } 

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

После установки позиции нашего активного тетромино нам нужно обновить игровое поле, изменив значения спрайтов и растровых изображений. Для этого мы создаем удобный метод с именем refresh . Этот метод вызывает два других пользовательских метода, которые выполняют тяжелую работу. Добавьте эти три новых метода в класс GameScene :

 class GameScene: SKScene { --- func refresh() { updateGameBitmap() updateGameBoard() } func updateGameBitmap() { gameBitmapDynamic.removeAll(keepCapacity: true) gameBitmapDynamic = gameBitmapStatic for row in 0..<activeTetromino.bitmap.count { for col in 0..<activeTetromino.bitmap[row].count { if activeTetromino.bitmap[row][col] > 0 { gameBitmapDynamic[activeTetromino.position.y + row][activeTetromino.position.x + col + 1] = activeTetromino.bitmap[row][col] } } } } func updateGameBoard() { let squares = gameBoard.children as [SKSpriteNode] var currentSquare = 0 for col in 0..<gameBitmapDynamic[0].count { for row in 0..<gameBitmapDynamic.count { let bit = gameBitmapDynamic[row][col] let square = squares[currentSquare] if square.color != colors[bit] { square.color = colors[bit] } ++currentSquare } } } } 

Метод updateGameBitmap — это место, где мы используем две переменные растрового изображения, которые мы объявили ранее, gameBitmapDynamic и gameBitmapStatic . Основная цель этого метода — обновить сцену новой позицией активного тетромино. Техника, которую мы применяем, заключается в замене динамического растрового изображения на статическое растровое изображение, временно удаляя активное тетромино. Наконец, мы снова применяем метод вложенного for in цикла, чтобы вставить активное tetromino с его новой позицией в динамическое растровое изображение.

Метод updateGameBoard с другой стороны, занимается обновлением фактических спрайтов. Спрайт — это графический элемент, представляющий блок в игре. Мы уже создали эти спрайты в нашем методе didMoveToView , поэтому нам нужно только обновить их цвета на основе добавленных тетромино. Мы используем то же самое for in метода for in loop, чтобы проанализировать каждое растровое изображение и использовать его значение для получения правильного цвета из массива colors .

  1. Очистите обработчик сенсорного события на данный момент. Это означает, что мы больше не будем использовать метод drawTetrominoAtPoint , поэтому удалите его из своего кода.

     class GameScene: SKScene { --- override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { /* Called when a touch begins */ } } 
  2. Мы обрабатываем падение кирпича при каждом обновлении кадра, но контролируем, насколько быстро кирпич падает, проверяя время, прошедшее с момента последнего события падения. Затем мы вызываем недавно переименованный метод moveTetrominoDown чтобы переместить активное тетромино вниз на один блок.

 class GameScene: SKScene { --- override func update(currentTime: CFTimeInterval) { /* Called before each frame is rendered */ if lastUpdate != nil { let elapsed = lastUpdate!.timeIntervalSinceNow * -1000.0 if elapsed > dropTime { moveTetrominoDown() } } } } 

Давайте теперь обновим метод moveTetrominoDown для обработки событий посадки.

 class GameScene: SKScene { --- override func update(currentTime: CFTimeInterval) {---} func moveTetrominoDown() { if landed() { gameBitmapStatic.removeAll(keepCapacity: true) gameBitmapStatic = gameBitmapDynamic activeTetromino = Tetromino() centerActiveTetromino() } else { activeTetromino.moveTo(.Down) } lastUpdate = NSDate() refresh() } } 

Сначала мы проверяем, упал ли кирпич на другой кирпич или на пол, позвонив landed . Этот пользовательский метод, который мы создали для обнаружения столкновений, возвращает Bool . Давайте обсудим, что такое булевы значения.

Булевы значения

В Swift Bool — это очень простое значение, представленное как true или false , и ничего более. В других языках логическое значение может быть представлено целым числом, ноль — ложь, а ненулевые значения — истина. Это также может быть представлено наличием или отсутствием объекта.

Это не относится к Swift, потому что его функция безопасности типов не позволяет логическим значениям содержать любое другое значение, такое как целое число. Булевы значения должны вычисляться только для одного из двух значений, поэтому проверка, является ли определенное целое число ненулевым, с использованием сокращенной записи, if someInt не будет работать. Условия должны принимать логическое значение, поэтому это должно быть записано как:

 if someInt == 0 { // Do something } 

Давайте теперь добавим метод landed прямо рядом с методом moveTetrominoDown .

 class GameScene: SKScene { --- func moveTetrominoDown() {---} func landed() -> Bool { let x = activeTetromino.position.x let y = activeTetromino.position.y + 1 for row in 0..<activeTetromino.bitmap.count { for col in 0..<activeTetromino.bitmap[row].count { if activeTetromino.bitmap[row][col] > 0 &amp;&amp; gameBitmapStatic[y + row][x + col + 1] > 0 { return true } } } return false } } 

Мы указываем тип возвращаемого значения, используя стрелку возврата ->, за которой следует имя возвращаемого типа, в данном случае Bool .

Далее мы получаем позицию активного тетромино, но со значением у, которое на один блок ниже. Это представляет области под активным tetromino, которые могут включать в себя не только пол, но и другие кирпичи.

Мы перебираем блоки активного тетромино и проверяем, перекрывает ли он уже существующий в игре блок, представленный gameBitmapStatic . Если это произойдет, мы немедленно остановим цикл и вернем true . Цикл продолжается до тех пор, пока не будут проверены все блоки. Если столкновения не обнаружено, мы возвращаем false .

Возвращаясь к методу moveTetrominoDown , если landed возвращает true, мы обновляем статическое растровое изображение, чтобы включить активное tetromino, которое стало частью неподвижных (или статических) объектов в нашей игре. Мы достигаем этого путем перезаписи статического растрового изображения значениями динамического растрового изображения. Затем мы создаем новое активное тетромино, чтобы заменить то, которое недавно приземлилось. Мы сохраняем lastUpdate метку этого момента в lastUpdate для проверки следующего обновления позже. Если столкновения нет, мы перемещаем активное тетромино на один блок вниз. После того, как все это сделано, мы выполняем refresh чтобы обновить растровые значения и спрайты с новой позицией активного тетромино.

Запустите ( ⌘ + R ) нашу игру и наблюдайте, как падающие тетромино приземляются на пол и друг на друга:

Падающие тетромино

И вот как мы реализуем обнаружение столкновений в нашей игре блоков. От вертикали теперь перейдем к горизонтальному движению.

Боковое движение

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

Оглядываясь назад на наш класс Tetromino , мы уже подготовили поведение движения с помощью метода moveTo :

 func moveTo(direction: Direction) { switch direction { case .Left: position = (position.x - 1, position.y) case .Right: position = (position.x + 1, position.y) case .Down: position = (position.x, position.y + 1) case .None: break } } 
  1. Это делает нашу работу немного проще. Чтобы учесть другие направления, давайте еще раз переименуем метод moveTetrominoTo в moveTetrominoTo . На этот раз он примет параметр для целевого направления. Давайте также обновим тело метода, чтобы отразить эти изменения. Там будет дополнительное обнаружение столкновений, чтобы учесть новые направления.
 func moveTetrominoTo(direction: Direction) { if collidedWith(direction) == false { activeTetromino.moveTo(direction) if direction == .Down { lastUpdate = NSDate() } } else { if direction == .Down { gameBitmapStatic.removeAll(keepCapacity: true) gameBitmapStatic = gameBitmapDynamic activeTetromino = Tetromino() centerActiveTetromino() lastUpdate = NSDate() } } refresh() } 

Предыдущая версия этого метода только проверяет, приземлился ли кирпич. Но теперь, когда у нас есть два дополнительных направления, которые мы должны учитывать, нам также нужно дополнить наш метод с полем. Давайте переименуем его в более подходящий метод collidedWith который принимает параметр направления. Также обновите его тело следующим кодом.

 func collidedWith(direction: Direction) -> Bool { func collided(x: Int, y: Int) -> Bool { for row in 0..<activeTetromino.bitmap.count { for col in 0..<activeTetromino.bitmap[row].count { if activeTetromino.bitmap[row][col] > 0 &amp;&amp; gameBitmapStatic[y + row][x + col + 1] > 0 { return true } } } return false } let x = activeTetromino.position.x let y = activeTetromino.position.y switch direction { case .Left: return collided(x - 1, y) case .Right: return collided(x + 1, y) case .Down: return collided(x, y + 1) case .None: return collided(x, y) } } 

Мы повторно используем существующий вложенный цикл for in из старого метода landed , оборачивая его в вспомогательную функцию с именем collided объявленную внутри метода collidedWith . Этот метод поможет в других направлениях.

Мы используем условный оператор switch для проверки всех доступных направлений, включая состояние оставшихся стационарных. Это определяет, столкнулся ли недавно созданный экземпляр тетромино с существующими тетромино. Если это так, то запускается событие « игра закончена» . Мы также используем это стационарное состояние для обнаружения вращательных столкновений.

Возвращаясь к методу moveTetrominoTo , мы проверяем, нет ли столкновений перед перемещением активного тетромино в требуемом направлении. Если столкновение обнаружено, мы выполняем посадку, как и раньше, не нужно беспокоиться о других направлениях.

  1. Чтобы заставить кирпич двигаться, нам нужно обрабатывать сенсорные события. Самый простой и простой способ — определить, на какой стороне игрового поля зарегистрировано касание. Касания с левой стороны переместят кирпич влево, а касания с правой стороны — вправо.

Давайте заполним наш пустой обработчик события touchesBegan кодом:

 override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { /* Called when a touch begins */ for touch: AnyObject in touches { let location = touch.locationInNode(self) let gameBoardFrame = gameBoard.calculateAccumulatedFrame() if location.x < gameBoardFrame.origin.x { moveTetrominoTo(.Left) } else if location.x > gameBoardFrame.origin.x + gameBoardFrame.width { moveTetrominoTo(.Right) } } } 

Внутри цикла мы получаем местоположение касания, а также фрейм игрового поля, используя calculateAccumulatedFrame который мы обсуждали ранее. Мы проверяем, находится ли касание слева от игрового поля или справа. Наконец, мы передаем соответствующее значение направления методу moveTetrominoTo .

  1. Давайте не забудем переименовать вызов moveTetrominoDown() в методе update :
 moveTetrominoTo(.Down) 

Сохраните и запустите ( ⌘ + R ) нашу игру, чтобы испытать немного более интерактивные кубики. Есть еще одно движение, которое мы хотим, чтобы наши кирпичи поддерживали, и это вращение. Мы реализуем это в следующей и последней части этой серии.