Знакомимся с языком Swift на примере игры Snake

goti3zbe-douszd51y8huhfg3ki.jpeg

Всем привет! В преддверии запуска курса «iOS-разработчик. Базовый курс» мы организовали очередной открытый урок. Этот вебинар рассчитан на людей, которые имеют опыт разработки на любых языках и платформах, однако желают ещё изучить язык Swift и освоить разработку под iOS. На уроке мы подробно разобрали синтаксис и ключевые конструкции языка Swift, познакомились с основными инструментами разработки.


Участники вебинара узнали:

  • что собой представляет язык Swift, каковы его особенности;
  • как среда разработки XCode помогает в процессе работы;
  • как создать простейшую игру под iOS.


Вебинар провёл Алексей Соболевский, iOS-разработчик в Яндексе.

Делаем Snake своими руками


Для работы мы использовали интегрированную среду разработки Xcode. Это удобная, бесплатная и функциональная среда, созданная компанией Apple.

В самом начале создали новый проект и выбрали базовый набор файлов «Game»:

vsluhgmqy_6rxlvmyqttv0filci.jpeg

Не мудрствуя лукаво, назвали проект «Snake». Все настройки оставили по умолчанию, убедившись лишь в том, что в строке Game Technology стоит SpriteKit.

Подробности создания проекта.

После выполнения вышеперечисленных действий в левой части окна отобразится список файлов, автоматически созданных для нашего проекта. Одним из наиболее важных файлов является AppDelegate.swift, который помогает системе связываться с нашим кодом, когда возникают какие-нибудь значимые события для приложения (запуск, пуш, переход по ссылке и т. п.). Код этого файла:

//
//  AppDelegate.swift
//  SnakeGame
//
//  Created by Alexey Sobolevsky on 15/09/2019.
//  Copyright  2019 Alexey Sobolevsky. All rights reserved.
//
 
import UIKit
 
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
 
    var window: UIWindow?
 
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        return true
    }
 
}


Не менее важными файлами являются GameScene.swift и GameViewController.swift. Класс GameScene создаёт сцену, а GameViewController отвечает за один экран приложения, который мы видим (один экран — один GameViewController). Конечно, это правило поддерживается не всегда, но в целом оно работает. Так как наше приложение довольно простое, у нас будет всего один GameViewController. С него и начнём.

Пишем GameViewController


Код по умолчанию мы удалим. У вью-контроллера есть несколько методов, которые срабатывают в зависимости от состояния экрана. Например, viewDidLoad() срабатывает, когда все элементы экрана уже загрузились, и экран вот-вот отобразится на смартфоне. Так как у нас игра, мы должны в наш вью-контроллер поместить игровую сцену (именно здесь будет бегать змейка и будут происходить все остальные события игры).

Создаём сцену:

let scene = GameScene(size: view.bounds.size)


let — константа и ключевое слово. В языке Swift используется также и ключевое слово var, необходимое для определения переменной. Используя var, мы можем изменять значение переменных многократно во время работы программы. Используя let, мы не можем изменить значение переменных после инициализации.

Теперь нам надо убедиться, что вью, в которое мы поместим созданную сцену, соответствует нужному типу. Для этого используем конструкцию guard — это то же самое, что if, только наоборот (if not):

guard let skView = view as? SKView else {
            return
        }


Убедившись, что элемент экрана соответствует нужному типу, добавляем к нему нашу сцену:

skView.presentScene(scene)


Также нужно, чтобы показывалось количество кадров в секунду (FPS):

skView.showsFPS = true


Потом отобразим количество элементов всех типов на сцене:

 skView.showsNodeCount = true


И сделаем так, чтобы элементы отображались на экране вне зависимости от их порядка в иерархии элементов:

skView.ignoresSiblingOrder = true


А ещё не забываем о том, что наша сцена должна растягиваться на всю ширину экрана:

scene.scaleMode = .resizeFill


Вот итоговый код файла GameViewController.swift:

import UIKit
import SpriteKit
import GameplayKit
 
class GameViewController: UIViewController {
 
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
 
        setup()
    }
 
    private func setup() {
        guard let skView = view as? SKView else {
            return
        }
 
        let scene = GameScene(size: view.bounds.size)
        skView.showsFPS = true
        skView.showsNodeCount = true
        skView.ignoresSiblingOrder = true
        scene.scaleMode = .resizeFill
        skView.presentScene(scene)
    }
}


Подробности создания файла GameViewController.swift.

Итак, мы создали сцену, но она пустая, поэтому если запустим эмулятор сейчас, увидим лишь чёрный экран.

Пишем GameScene


Как и в прошлый раз, большую часть кода удаляем, а потом выполняем необходимые настройки сцены. Здесь также есть свои методы. Например, так как мы добавили нашу сцену во ViewController, нам нужен метод didMove():

override func didMove(to view: SKView) {
        setup(in: view)
    }


Далее, когда запускается игра, на каждый кадр происходит вызов метода Update():

override func update(_ currentTime: TimeInterval) {
        snake?.move()
    }


И ещё нам понадобится несколько обработчиков нажатия на экран:

override func touchesEnded(_ touches: Set, with event: UIEvent?) {
        guard let touchedNode = findTouchedNode(with: touches) else {
            return
        }
override func touchesCancelled(_ touches: Set, with event: UIEvent?) {
        guard let touchedNode = findTouchedNode(with: touches) else {
            return
        }


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

backgroundColor = SKColor.white


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

physicsWorld.gravity = .zero
        physicsWorld.contactDelegate = self
        physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
        physicsBody?.allowsRotation = false
        physicsBody?.categoryBitMask = CollisionCategories.edgeBody
        physicsBody?.collisionBitMask = CollisionCategories.snake | CollisionCategories.snakeHead
        view.showsPhysics = true


Теперь создадим кнопки:

let counterClockwiseButton = ControlsFactory.makeButton(at: CGPoint(x: scene.frame.minX + 30, y: scene.frame.minY + 50),
                                             name: .counterClockwiseButtonName)
        addChild(counterClockwiseButton)
 
        let clockwiseButton = ControlsFactory.makeButton(at: CGPoint(x: scene.frame.maxX - 90, y: scene.frame.minY + 50),
                                             name: .clockwiseButtonName)
        addChild(clockwiseButton)


Когда вы написали какой-то участок кода, следует подумать о том, можно ли код улучшить или отрефакторить так, чтобы его можно было в дальнейшем переиспользовать. Смотрите, у нас на экране по сути две кнопки, для создания которых используется один и тот же код. А значит, этот код можно вынести в отдельную функцию. Для этого создадим новый класс и, соответственно, файл ControlsFactory.swift со следующим кодом:

import SpriteKit
 
final class ControlsFactory {
 
    static func makeButton(at position: CGPoint, name: String) -> SKShapeNode {
        let button = SKShapeNode()
        button.path = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 45, height: 45)).cgPath
        button.position = position
        button.fillColor = .gray
        button.strokeColor = UIColor.lightGray.withAlphaComponent(0.7)
        button.lineWidth = 10
        button.name = name
        return button
    }
 
}


Чтобы нарисовать рандомное яблоко, которое будет «кушать» наша змейка, создаём класс Apple и файл Apple.swift:

import SpriteKit
 
final class Apple: SKShapeNode {
    let diameter: CGFloat = 10
 
    convenience init(at point: CGPoint) {
        self.init()
 
        path = UIBezierPath(ovalIn: CGRect(x: -diameter/2, y: -diameter/2, width: diameter, height: diameter)).cgPath
        fillColor = .red
        strokeColor = UIColor.red.withAlphaComponent(0.7)
        lineWidth = 5
        position = point
        physicsBody = SKPhysicsBody(circleOfRadius: diameter / 2, center: .zero)
        physicsBody?.categoryBitMask = CollisionCategories.apple
    }
 
}


И описываем наше яблоко функцией createApple() в GameScene.swift:

private func createApple() {
        let padding: UInt32 = 15
        let randX = CGFloat(arc4random_uniform(UInt32(gameFrameRect.maxX) - padding) + padding)
        let randY = CGFloat(arc4random_uniform(UInt32(gameFrameRect.maxY) - padding) + padding)
        let apple = Apple(at: CGPoint(x: randX, y: randY).relative(to: gameFrameRect))
        gameFrameView.addChild(apple)
    }


Что же, пришла очередь и для змеи. Она будет состоять из двух частей: тела (SnakeBodyPart.swift) и головы (SnakeHead.swift).

Код SnakeBodyPart.swift:

import SpriteKit
 
class SnakeBodyPart: SKShapeNode {
 
    init(at point: CGPoint, diameter: CGFloat = 10.0) {
        super.init()
        path = UIBezierPath(ovalIn: CGRect(x: -diameter/2, y: -diameter/2, width: diameter, height: diameter)).cgPath
        fillColor = .green
        strokeColor = UIColor.green.withAlphaComponent(0.7)
        lineWidth = 5
        position = point
 
        physicsBody = SKPhysicsBody(circleOfRadius: diameter - 4, center: .zero)
        physicsBody?.isDynamic = true
        physicsBody?.categoryBitMask = CollisionCategories.snake
        physicsBody?.contactTestBitMask = CollisionCategories.edgeBody | CollisionCategories.apple
    }
 
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}


Код SnakeHead.swift:

import SpriteKit
 
final class SnakeHead: SnakeBodyPart {
 
    init(at point: CGPoint) {
        super.init(at: point, diameter: 20)
 
        physicsBody?.categoryBitMask = CollisionCategories.snakeHead
        physicsBody?.contactTestBitMask = CollisionCategories.edgeBody | CollisionCategories.apple
    }
 
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}


Однако не будем вас утомлять описанием каждой строчки, т. к. подробности создания файла GameScene.swift и других классов хорошо отображены в видео. Предлагаем лишь посмотреть итоговый код GameScene.swift:

import SpriteKit
import GameplayKit
 
class GameScene: SKScene {
 
    var gameFrameRect: CGRect = .zero
    var gameFrameView: SKShapeNode!
    var startButton: SKLabelNode!
    var stopButton: SKLabelNode!
    var snake: Snake?
 
    override func didMove(to view: SKView) {
        setup(in: view)
    }
 
    override func update(_ currentTime: TimeInterval) {
        snake?.move()
    }
 
    override func touchesBegan(_ touches: Set, with event: UIEvent?) {
        guard let touchedNode = findTouchedNode(with: touches) else {
            return
        }
 
        if let shapeNode = touchedNode as? SKShapeNode,
            touchedNode.name == .counterClockwiseButtonName || touchedNode.name == .clockwiseButtonName {
            shapeNode.fillColor = .green
            if touchedNode.name == .counterClockwiseButtonName {
                snake?.moveCounterClockwise()
            } else if touchedNode.name == .clockwiseButtonName {
                snake?.moveClockwise()
            }
        } else if touchedNode.name == .startButtonName {
            start()
        } else if touchedNode.name == .stopButtonName {
            stop()
        }
    }
 
    override func touchesEnded(_ touches: Set, with event: UIEvent?) {
        guard let touchedNode = findTouchedNode(with: touches) else {
            return
        }
 
        if let shapeNode = touchedNode as? SKShapeNode,
            touchedNode.name == .counterClockwiseButtonName || touchedNode.name == .clockwiseButtonName {
            shapeNode.fillColor = .gray
        }
    }
 
    override func touchesCancelled(_ touches: Set, with event: UIEvent?) {
        guard let touchedNode = findTouchedNode(with: touches) else {
            return
        }
 
        if let shapeNode = touchedNode as? SKShapeNode,
            touchedNode.name == .counterClockwiseButtonName || touchedNode.name == .clockwiseButtonName {
            shapeNode.fillColor = .gray
        }
    }
 
    // MARK: -
 
    private func start() {
        guard let scene = scene else { return }
 
        snake = Snake(at: CGPoint(x: scene.frame.midX, y: scene.frame.midY))
        gameFrameView.addChild(snake!)
 
        createApple()
 
        startButton.isHidden = true
        stopButton.isHidden = false
    }
 
    private func stop() {
        snake = nil
        gameFrameView.removeAllChildren()
 
        startButton.isHidden = false
        stopButton.isHidden = true
    }
 
    private func setup(in view: SKView) {
        backgroundColor = SKColor.white
 
        physicsWorld.gravity = .zero
        physicsWorld.contactDelegate = self
        physicsBody = SKPhysicsBody(edgeLoopFrom: frame)
        physicsBody?.allowsRotation = false
        physicsBody?.categoryBitMask = CollisionCategories.edgeBody
        physicsBody?.collisionBitMask = CollisionCategories.snake | CollisionCategories.snakeHead
        view.showsPhysics = true
 
        let margin: CGFloat = 20
        let gameFrame = frame.inset(by: view.safeAreaInsets)
        gameFrameRect = CGRect(x: margin, y: margin + view.safeAreaInsets.top + 55,
                               width: gameFrame.width - margin * 2, height: gameFrame.height - margin * 2 - 55)
        drawGameFrame()
 
        guard let scene = view.scene else {
            return
        }
 
        let counterClockwiseButton = ControlsFactory.makeButton(at: CGPoint(x: scene.frame.minX + 30, y: scene.frame.minY + 50),
                                                                name: .counterClockwiseButtonName)
        addChild(counterClockwiseButton)
 
        let clockwiseButton = ControlsFactory.makeButton(at: CGPoint(x: scene.frame.maxX - 90, y: scene.frame.minY + 50),
                                                         name: .clockwiseButtonName)
        addChild(clockwiseButton)
 
        startButton = SKLabelNode(text: "S T A R T")
        startButton.position = CGPoint(x: scene.frame.midX, y: 55)
        startButton.fontSize = 40
        startButton.fontColor = .green
        startButton.name = .startButtonName
        addChild(startButton)
 
        stopButton = SKLabelNode(text: "S T O P")
        stopButton.position = CGPoint(x: scene.frame.midX, y: 55)
        stopButton.fontSize = 40
        stopButton.fontColor = .red
        stopButton.name = .stopButtonName
        stopButton.isHidden = true
        addChild(stopButton)
    }
 
    final func drawGameFrame() {
        gameFrameView = SKShapeNode(rect: gameFrameRect)
        gameFrameView.fillColor = .lightGray
        gameFrameView.lineWidth = 2
        gameFrameView.strokeColor = .green
        addChild(gameFrameView)
    }
 
    private func findTouchedNode(with touches: Set) -> SKNode? {
        return touches.map { [unowned self] touch in touch.location(in: self) }
            .map { atPoint($0) }
            .first
    }
 
    private func createApple() {
        let padding: UInt32 = 15
        let randX = CGFloat(arc4random_uniform(UInt32(gameFrameRect.maxX) - padding) + padding)
        let randY = CGFloat(arc4random_uniform(UInt32(gameFrameRect.maxY) - padding) + padding)
        let apple = Apple(at: CGPoint(x: randX, y: randY).relative(to: gameFrameRect))
        gameFrameView.addChild(apple)
    }
 
}
 
// MARK: - SKPhysicsContactDelegate
 
extension GameScene: SKPhysicsContactDelegate {
 
    func didBegin(_ contact: SKPhysicsContact) {
        var contactMask = contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask
        contactMask ^= CollisionCategories.snakeHead
 
        switch contactMask {
        case CollisionCategories.apple:
            let apple = contact.bodyA.node is Apple ? contact.bodyA.node : contact.bodyB.node
            snake?.addBodyPart()
            apple?.removeFromParent()
            createApple()
 
        case CollisionCategories.edgeBody:
            stop()
            break
 
        default:
            break
        }
    }
 
}
 
private extension String {
    static let counterClockwiseButtonName = "counterClockwiseButton"
    static let clockwiseButtonName = "clockwiseButton"
 
    static let startButtonName = "startButton"
    static let stopButtonName = "stopButton"
}


Результатом работы стала простейшая игра Snake:

x7i7wtj2zk-1y-xb-hkj4z-prg0.jpeg

На написание игры у нас ушло около полутора часов. Если хотите получить навыки программирования на Swift, повторите все этапы самостоятельно. Кстати здесь вы получите полный доступ ко всем файлам кода, которые использовались в данном проекте.

© Habrahabr.ru