Swiftで花火を作った話


#はじめに

![2017-08-09 14_19_04.gif](https://qiita-image-store.s3.amazonaws.com/0/77778/1012253a-b033-9360-ebec-b9ec295a20bb.gif "2017-08-09 14_19_04.gif")

LIVEで配信しながら何か作ろう・・・

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">夜なんかテーマいただければインスタライブしながら開発しますよ〜</p>&mdash; () =＞ { はるふ } (@_ha1f) <a href="https://twitter.com/_ha1f/status/890803965058719744">2017年7月28日</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>


そういって花火を作ることになりました。

完成品のリポジトリはこちらです
[github.com/ha1f/fireworksClock](https://github.com/ha1f/fireworksClock)

# 調査

適当に検索していると、shu223さんのすごい記事が引っかかりますので、これをSwift化するところから始めます。

[UIKit上でパーティクルエフェクトを表示する](http://d.hatena.ne.jp/shu223/20130315/1363298992)

CAEmitterLayerを重ねて、そのレイヤ上で花火を表示しています。

```swift
import UIKit

class FireworksView: UIView {
    
    let emitterLayer = CAEmitterLayer()
    lazy var particleImage: UIImage = {
        let imageSize = CGSize(width: 50, height: 50)
        let margin: CGFloat = 0
        let circleSize = CGSize(width: imageSize.width - margin * 2, height: imageSize.height - margin * 2)
        UIGraphicsBeginImageContext(imageSize)
        if let context = UIGraphicsGetCurrentContext() {
            context.setFillColor(UIColor.red.cgColor)
            context.fillEllipse(in: CGRect(origin: CGPoint(x: margin, y: margin), size: circleSize))
        }
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsPopContext()
        return image!
    }()
    
    func setup() {
        emitterLayer.emitterPosition = CGPoint(x: self.bounds.midX, y: self.bounds.maxY)
        emitterLayer.emitterMode = kCAEmitterLayerAdditive
        
        let baseCell = CAEmitterCell()
        baseCell.emissionLongitude = -CGFloat.pi / 2
        baseCell.emissionLatitude = 0
        baseCell.emissionRange = CGFloat.pi / 5
        baseCell.lifetime = 2.0
        baseCell.birthRate = 0.5
        baseCell.velocity = 400
        baseCell.velocityRange = 50
        baseCell.yAcceleration = 300
        
        // 上昇中のパーティクルの発生源
        let risingCell = CAEmitterCell()
        risingCell.contents = particleImage.cgImage
        risingCell.emissionLongitude = (4 * CGFloat.pi) / 2
        risingCell.emissionRange = CGFloat.pi / 7
        risingCell.scale = 0.3
        risingCell.velocity = 100
        risingCell.birthRate = 50
        risingCell.lifetime = 1.5
        risingCell.yAcceleration = 350
        risingCell.alphaSpeed = -0.7
        risingCell.scaleSpeed = -0.1
        risingCell.scaleRange = 0.1
        risingCell.beginTime = 0.01
        risingCell.duration = 0.7
        
        // 破裂後に飛散するパーティクルの発生源
        let sparkCell = CAEmitterCell()
        sparkCell.contents = particleImage.cgImage
        // パーティクルを発生する角度の範囲
        sparkCell.emissionRange = 2 * CGFloat.pi
        // １秒間に生成するパーティクルの数
        sparkCell.birthRate = 8000
        sparkCell.scale = 0.2
        sparkCell.velocity = 130
        // パーティクルが発生してから消えるまでの時間(s)
        sparkCell.lifetime = 2.0
        sparkCell.yAcceleration = 80
        sparkCell.beginTime = CFTimeInterval(risingCell.lifetime)
        sparkCell.duration = 0.1
        sparkCell.alphaSpeed = -0.1
        sparkCell.scaleSpeed = -0.1
        
        // baseCellからrisingCellとsparkCellを発生させる
        baseCell.emitterCells = [risingCell, sparkCell]
        
        // baseCellはemitterLayerから発生させる
        self.emitterLayer.emitterCells = [baseCell]
        self.layer.addSublayer(emitterLayer)
    }
}

class ViewController: UIViewController {

    @IBOutlet weak var fireworksView: FireworksView! {
        didSet {
            fireworksView.setup()
        }
    }
}
```

画像を用意するのが大変だったので、CoreGraphicsを使って丸い画像を生成しています。

```swift
lazy var particleImage: UIImage = {
    let imageSize = CGSize(width: 50, height: 50)
    let margin: CGFloat = 0
    let circleSize = CGSize(width: imageSize.width - margin * 2, height: imageSize.height - margin * 2)
    UIGraphicsBeginImageContext(imageSize)
    if let context = UIGraphicsGetCurrentContext() {
        context.setFillColor(UIColor.red.cgColor)
        context.fillEllipse(in: CGRect(origin: CGPoint(x: margin, y: margin), size: circleSize))
    }
    let image = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsPopContext()
    return image!
}()
```

仕組みとしてはパーティクルを全方向に放出するオブジェクトと、パーティクルを下向きに放出しながら進むオブジェクトを同時に飛ばし、それぞれのタイミングをずらすことで、軌跡と花火本体を実現しています。

baseCellが二つ共を打ち上げます。sparkCellが爆発する花火放出オブジェクトです。

これで、赤い花火だけが上がる状態になります。

# 花火の終了間際をキラつかせる

花火、すべてのsparkが同時に消えるわけではなくて、最後はバラバラに消えてキラキラするので、

```swift
sparkCell.lifetime = 2.0
sparkCell.lifetimeRange = 0.4
```

にしてsparkの寿命に幅をもたせます

# 二色にする

全部単色だなーと思ったので、２色にしたい。
実際の花火玉とは違いますが、密度が半分の花火を2つ、同じ場所に打ち上げればよいのでは？
ということで、赤と黄で実装してみました。

![名称未設定.png](https://qiita-image-store.s3.amazonaws.com/0/77778/ca7620d7-7304-8a0f-4aba-b40bb024bcee.png "名称未設定.png")

分かるでしょうか？２色の重なりが必ず黄色が上になってしまう・・・
でもここはライブ。悩んでいると、もっと多くの花火を上げてかさねればよいのでは、と。

1/4密度の赤、1/2密度の黄、1/4密度の赤という3つの花火を打ち上げて重ねることで、よい重なりを実現できました！（コードではもっと雑な比率にしていますが）
同様の手法で、任意の配分で色を混ぜることができるようになりました

# カラフルにする

ここまで作ったところで、shuさんのサンプルではもともとカラフルだったことを思い出します。

baseCellに対して色をカラフルにするようにします。更に、sparkCell自体に色があると重ねられてしまうので、sparkCellを白にします。これにより、cellにセットした色が反映されるようになりました。

```swift
// パーティクルの色
baseCell.color = UIColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0).cgColor
baseCell.redRange   = 0.9
baseCell.greenRange = 0.9
baseCell.blueRange  = 0.9
```

# 群衆を追加する

何かが足りない。そう、花火を上げてるのに見てる人がいない！

というわけで、群衆を追加します。

我らが[いらすとや](http://www.irasutoya.com/)さんで「後ろ姿」を検索すると10枚程度でてきたので、これを追加することに決めます。

もちろん画像はランダムで選択します。

x座標は画面の横幅からランダムに決めます。ただし、frameのxはあくまで左上なので、開始位置を0.0にしてしまうと左端に人が来ません。
よって、x座標は `arc4random_uniform(幅 + 画像幅) - 画像幅` として求めます。

y座標はすべて同じだとみんな同じ身長になってしまって違和感があるので、こちらも乱数である程度の幅をもたせます。

```swift
class CrowdView: UIView {
    private var personViews: [UIView] = []
    
    static let images = [
        #imageLiteral(resourceName: "stand2_back01_boy.png"), #imageLiteral(resourceName: "stand2_back02_girl.png"), #imageLiteral(resourceName: "stand2_back03_youngman.png"), #imageLiteral(resourceName: "stand2_back05_man.png"), #imageLiteral(resourceName: "stand2_back06_woman.png"), #imageLiteral(resourceName: "stand2_back07_ojisan.png"), #imageLiteral(resourceName: "stand2_back08_obasan.png"), #imageLiteral(resourceName: "stand2_back09_ojiisan.png"), #imageLiteral(resourceName: "stand2_back10_obaasan.png")
    ]
    
    static let imageSize = CGSize(width: 90, height: 150)
    // 足元の食い込み
    static let imageBottomOffset: CGFloat = 20
    static let imageBottomOffsetRange: CGFloat = 26
    
    private func appendPersonView(_ view: UIView) {
        personViews.append(view)
        self.addSubview(view)
    }
    
    private func resetPersons() {
        personViews.forEach { view in
            view.removeFromSuperview()
        }
        personViews = []
    }
    
    func setup(_ number: Int = 5) {
        let candidatesCount = CrowdView.images.count
        (0..<number)
            .map { _ in
                let image = CrowdView.images[Int(arc4random_uniform(UInt32(candidatesCount)))]
                let xPos = CGFloat(arc4random_uniform(UInt32(self.bounds.width + CrowdView.imageSize.width))) - CrowdView.imageSize.width
                let yOffset = CrowdView.imageBottomOffset + CGFloat(arc4random_uniform(UInt32(CrowdView.imageBottomOffsetRange))) - CrowdView.imageBottomOffsetRange / 2
                let imageView = UIImageView(frame: CGRect(origin: CGPoint(x: xPos, y: self.bounds.height - CrowdView.imageSize.height + yOffset), size: CrowdView.imageSize))
                imageView.image = image
                return imageView
            }
            .forEach { imageView in
                self.appendPersonView(imageView)
        }
    }
}
```

# ちょっときれいにする

本当はFireworks構造体にbaseCell生成に必要なパメータまとめたかったけれど、時間の都合上ご容赦ください・・・

```swift
class FireworksView: UIView {
    
    static let particlesCount = 20000
    
    let emitterLayer = CAEmitterLayer()
    let sparkDelay: Float = 1.5
    var emitterCells: [CAEmitterCell] {
        return self.emitterLayer.emitterCells ?? []
    }
    
    /// returns circle image filled with specified color
    private static func generateCircleImage(with color: UIColor, size: CGSize = CGSize(width: 25, height: 25), inset: UIEdgeInsets = .zero) -> UIImage {
        let circleSize = CGSize(width: size.width - inset.left - inset.right, height: size.height - inset.top - inset.bottom)
        UIGraphicsBeginImageContext(size)
        if let context = UIGraphicsGetCurrentContext() {
            context.setFillColor(color.cgColor)
            context.fillEllipse(in: CGRect(origin: CGPoint(x: inset.left, y: inset.top), size: circleSize))
        }
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsPopContext()
        return image!
    }
    
    private func generateSparkCell(particle: UIImage, birthRate: Float) -> CAEmitterCell {
        let sparkCell = CAEmitterCell()
        sparkCell.contents = particle.cgImage
        sparkCell.emissionRange = CGFloat.pi * 2
        sparkCell.birthRate = birthRate
        sparkCell.scale = 0.2
        sparkCell.velocity = 130
        sparkCell.lifetime = 2.0
        sparkCell.lifetimeRange = 0.4
        sparkCell.yAcceleration = 80
        sparkCell.beginTime = CFTimeInterval(sparkDelay)
        sparkCell.duration = 0.1
        sparkCell.alphaSpeed = -0.1
        sparkCell.scaleSpeed = -0.1
        return sparkCell
    }
    
    private func generateSparkCell(color: UIColor, birthRate: Float) -> CAEmitterCell {
        return generateSparkCell(particle: FireworksView.generateCircleImage(with: color), birthRate: birthRate)
    }
    
    private func generateBaseCell(birthRate: Float, cells: [CAEmitterCell], isColorful: Bool = false) -> CAEmitterCell {
        let base = CAEmitterCell()
        base.emissionLongitude = -CGFloat.pi / 2
        base.emissionLatitude = 0
        base.emissionRange = CGFloat.pi / 5
        base.lifetime = 2.0
        base.birthRate = birthRate
        base.velocity = 400
        base.velocityRange = 50
        base.yAcceleration = 300
        base.emitterCells = cells
        if isColorful {
            base.color = UIColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0).cgColor
            base.redRange = 0.9
            base.greenRange = 0.9
            base.blueRange = 0.9
        }
        return base
    }
    
    private func generateBaseCell(birthRate: Float, risingCell: CAEmitterCell, colors: [UIColor], isColorful: Bool = false) -> CAEmitterCell {
        let sparkRate = Float(FireworksView.particlesCount / colors.count)
        let cells = colors.map { color in
            generateSparkCell(color: color, birthRate: sparkRate)
        }
        return generateBaseCell(birthRate: birthRate, cells: [risingCell] + cells, isColorful: isColorful)
    }
    
    private func generateRisingCell(particle: UIImage = FireworksView.generateCircleImage(with: UIColor.white)) -> CAEmitterCell {
        let risingCell = CAEmitterCell()
        risingCell.contents = particle.cgImage
        risingCell.emissionLongitude = (4 * CGFloat.pi) / 2
        risingCell.emissionRange = CGFloat.pi / 7
        risingCell.scale = 0.2
        risingCell.velocity = 100
        risingCell.birthRate = 50
        risingCell.lifetime = self.sparkDelay
        risingCell.yAcceleration = 200
        risingCell.alphaSpeed = -0.7
        risingCell.scaleSpeed = -0.1
        risingCell.scaleRange = 0.1
        risingCell.beginTime = 0.01
        risingCell.duration = 0.9
        return risingCell
    }
    
    func resetEmitPosition() {
        // 下過ぎないように
        emitterLayer.emitterPosition = CGPoint(x: self.bounds.midX, y: min(self.bounds.maxY, 450))
    }
    

    func setup() {
        emitterLayer.emitterMode = kCAEmitterLayerAdditive
        
        let risingCell = self.generateRisingCell(particle: FireworksView.generateCircleImage(with: UIColor.red))
        
        // 花火うちあげ元
        let blueWhiteBase = generateBaseCell(birthRate: 0.2, risingCell: risingCell, colors: [.blue, .blue, .white, .blue])
        let redYellowBase = generateBaseCell(birthRate: 0.7, risingCell: risingCell, colors: [.red, .yellow, .red, .yellow])
        let greenBase = generateBaseCell(birthRate: 0.3, risingCell: risingCell, colors: [.green])
        let colorfulBase = generateBaseCell(birthRate: 1.7, risingCell: generateRisingCell(), colors: [.white], isColorful: true)
        
        self.emitterLayer.emitterCells = [redYellowBase, blueWhiteBase, greenBase, colorfulBase]
        self.layer.addSublayer(emitterLayer)
        resetEmitPosition()
    }
}
```