SwiftUIであのダメージ与えたった感ある吹き出しを作ってみる

## はじめに

さて、お気づきの方もいるかもしれませんが、そうです、あの記事の続編(なんちゃって続編)です。
以前、体力ゲージを作成して、2つの世界線にリスペクトを示した、例のやつです。

[SwiftUIであの体力ゲージを作ってみる](https://qiita.com/haguhoms/items/66f7a2e55f553d12cf45)

今回もまた新たな世界線にリスペクト示しに来ました、と。

ゲームでよくある、ダメージ与えた時の演出。
いろんな表現がありますね。
RPGでは、**主人公is自分**な感じで、やっぱり気持ちよくダメージ与えたった感ほしいじゃないですか。
ってことで、あの世界線のダメージバルーン(なんて呼ぶかはわからないが、一旦ここではそう呼ぶ)を作ってみました！

## 成果物

デモ作った時はばちばちにその世界線の敵画像を使ってやっていたので、非常にココロオドル開発だったわけですが、できあがりに気づいたんです。
・・・使っちゃだめじゃね？って。
そう思わせてしまい記事に集中してもらえないのも嫌だし、今回はみんな大好きいらすとやから画像を拝借しました。
幸いかあいい死神くんがいたので、この子にダメージ与えたいと思います。

さて、みなさんは魔法使いです。
死神に炎の魔法を使いましょう。
○ラでもファ○アでも、各々好きな名前でいったりましょう！

![Jun-06-2023 23-21-44.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81434/0cc976b5-1c4e-165f-fd48-9e73c7934873.gif)

**ぱぁぁぁぁぁぁぁぁぁぁぁんっっっっ！！！！！**

はい、では、実装の方を見ていきます。

## 実装

こちらは、例によって例のごとく、CADisplayLinkを使用したアニメーションになります。
約束事のように一応書いておくので、興味があったら中身覗いてください。
※CADisplayLinkについては過去にも記事を書いているので説明は省きます
※AsyncStreamを使用するところは[ここ](https://stackoverflow.com/a/75514757)を参考にしました

本題じゃないので、折りたたんでおく

<details>
<summary>詳細</summary>

```swift:CADisplayLink+.swift
import UIKit

@MainActor
extension CADisplayLink {
    static func events() -> AsyncStream<CADisplayLink> {
        AsyncStream { continuation in
            let displayLink = DisplayLink { displayLink in
                continuation.yield(displayLink)
            }

            continuation.onTermination = { _ in
                Task { await displayLink.stop() }
            }
        }
    }
}

private extension CADisplayLink {
    @MainActor
    private class DisplayLink: NSObject {
        private var displayLink: CADisplayLink!
        private let handler: (CADisplayLink) -> Void

        init(mode: RunLoop.Mode = .default, handler: @escaping (CADisplayLink) -> Void) {
            self.handler = handler
            super.init()

            displayLink = CADisplayLink(target: self, selector: #selector(handle(displayLink:)))
            displayLink.add(to: .main, forMode: mode)
        }

        func stop() {
            displayLink.invalidate()
        }

        @objc func handle(displayLink: CADisplayLink) {
            handler(displayLink)
        }
    }
}
```

</details>

で、EasingをSwiftUIで使えるように定義したもの
(長いのでこちらも)

<details>
<summary>詳細</summary>

```swift:CustomEasing.swift
import CoreGraphics
import SwiftUI

enum CustomEasing {
    enum easeIn {
        case sine
        case quad
        case cubic
        case quart
        case quint
        case expo
        case circ
        case back

        func timingCurve(duration: CGFloat = 0.2) -> Animation {
            switch self {
            case .sine: return .timingCurve(0.12, 0, 0.39, 0, duration: duration)
            case .quad: return .timingCurve(0.11, 0, 0.5, 0, duration: duration)
            case .cubic: return .timingCurve(0.32, 0, 0.67, 0, duration: duration)
            case .quart: return .timingCurve(0.5, 0, 0.75, 0, duration: duration)
            case .quint: return .timingCurve(0.64, 0, 0.78, 0, duration: duration)
            case .expo: return .timingCurve(0.7, 0, 0.84, 0, duration: duration)
            case .circ: return .timingCurve(0.55, 0, 1, 0.45, duration: duration)
            case .back: return .timingCurve(0.36, 0, 0.66, -0.56, duration: duration)
            }
        }

        func progress(elapsed: CGFloat) -> CGFloat {
            switch self {
            case .sine:
                return 1 - cos((elapsed * .pi) / 2)
            case .quad:
                return elapsed * elapsed
            case .cubic:
                return elapsed * elapsed * elapsed
            case .quart:
                return elapsed * elapsed * elapsed * elapsed
            case .quint:
                return elapsed * elapsed * elapsed * elapsed * elapsed
            case .expo:
                return elapsed == 0 ? 0 : pow(2, 10 * elapsed - 10)
            case .circ:
                return 1 - sqrt(1 - pow(elapsed, 2))
            case .back:
                let c1 = 1.70158
                let c3 = c1 + 1
                return c3 * elapsed * elapsed * elapsed - c1 * elapsed * elapsed
            }
        }
    }

    enum easeOut {
        case sine
        case quad
        case cubic
        case quart
        case quint
        case expo
        case circ
        case back

        func timingCurve(duration: CGFloat) -> Animation {
            switch self {
            case .sine: return .timingCurve(0.61, 1, 0.88, 1, duration: duration)
            case .quad: return .timingCurve(0.5, 1, 0.89, 1, duration: duration)
            case .cubic: return .timingCurve(0.33, 1, 0.68, 1, duration: duration)
            case .quart: return .timingCurve(0.25, 1, 0.5, 1, duration: duration)
            case .quint: return .timingCurve(0.22, 1, 0.36, 1, duration: duration)
            case .expo: return .timingCurve(0.16, 1, 0.3, 1, duration: duration)
            case .circ: return .timingCurve(0, 0.55, 0.45, 1, duration: duration)
            case .back: return .timingCurve(0.34, 1.56, 0.64, 1, duration: duration)
            }
        }

        func progress(elapsed: CGFloat) -> CGFloat {
            switch self {
            case .sine:
                return sin((elapsed * .pi) / 2)
            case .quad:
                return 1 - (1 - elapsed) * (1 - elapsed)
            case .cubic:
                return 1 - pow(1 - elapsed, 3)
            case .quart:
                return 1 - pow(1 - elapsed, 4)
            case .quint:
                return 1 - pow(1 - elapsed, 5)
            case .expo:
                return elapsed == 1 ? 1 : 1 - pow(2, -10 * elapsed)
            case .circ:
                return sqrt(1 - pow(elapsed - 1, 2))
            case .back:
                let c1 = 1.70158
                let c3 = c1 + 1

                return 1 + c3 * pow(elapsed - 1, 3) + c1 * pow(elapsed - 1, 2)
            }
        }
    }

    enum easeInOut {
        case sine
        case quad
        case cubic
        case quart
        case quint
        case expo
        case circ
        case back

        func timingCurve(duration: CGFloat) -> Animation {
            switch self {
            case .sine: return .timingCurve(0.37, 0, 0.63, 1, duration: duration)
            case .quad: return .timingCurve(0.45, 0, 0.55, 1, duration: duration)
            case .cubic: return .timingCurve(0.65, 0, 0.35, 1, duration: duration)
            case .quart: return .timingCurve(0.76, 0, 0.24, 1, duration: duration)
            case .quint: return .timingCurve(0.83, 0, 0.17, 1, duration: duration)
            case .expo: return .timingCurve(0.87, 0, 0.13, 1, duration: duration)
            case .circ: return .timingCurve(0.85, 0, 0.15, 1, duration: duration)
            case .back: return .timingCurve(0.68, -0.6, 0.32, 1.6, duration: duration)
            }
        }

        func progress(elapsed: CGFloat) -> CGFloat {
            switch self {
            case .sine:
                return -(cos(.pi * elapsed) - 1) / 2
            case .quad:
                return elapsed < 0.5 ? 2 * elapsed * elapsed : 1 - pow(-2 * elapsed + 2, 2) / 2
            case .cubic:
                return elapsed < 0.5 ? 4 * elapsed * elapsed * elapsed : 1 - pow(-2 * elapsed + 2, 3) / 2
            case .quart:
                return elapsed < 0.5 ? 8 * elapsed * elapsed * elapsed * elapsed : 1 - pow(-2 * elapsed + 2, 4) / 2
            case .quint:
                return elapsed < 0.5 ? 16 * elapsed * elapsed * elapsed * elapsed * elapsed : 1 - pow(-2 * elapsed + 2, 5) / 2
            case .expo:
                return elapsed == 0
                  ? 0
                  : elapsed == 1
                  ? 1
                  : elapsed < 0.5 ? pow(2, 20 * elapsed - 10) / 2
                  : (2 - pow(2, -20 * elapsed + 10)) / 2
            case .circ:
                return elapsed < 0.5
                  ? (1 - sqrt(1 - pow(2 * elapsed, 2))) / 2
                  : (sqrt(1 - pow(-2 * elapsed + 2, 2)) + 1) / 2
            case .back:
                let c1 = 1.70158
                let c2 = c1 * 1.525

                return elapsed < 0.5
                  ? (pow(2 * elapsed, 2) * ((c2 + 1) * 2 * elapsed - c2)) / 2
                  : (pow(2 * elapsed - 2, 2) * ((c2 + 1) * (elapsed * 2 - 2) + c2) + 2) / 2
            }
        }
    }
}
```
</details>


それでは、今回使った要素それぞれについて見ていきます。

### 共通

まずは共通的に必要なものと、全体構成を。
登場人物
- 敵 (enemy)
- 火の玉 (fire)
- ダメージバルーン (content)
- 吹き出し (balloon)
- ダメージ値 (damage)

```swift:DoxxxxxQuxxxDamageBalloonView.swift
struct DoxxxxxQuxxxDamageBalloonView {
    @State private var startTime: CFTimeInterval = .zero
    @State private var progress: CFTimeInterval = .zero
    @State private var task: Task<Void, Never>?

    // アニメーション時間
    private enum AnimateDuration {
        case enemy
        case fire
        case content
        case balloonExpansion
        case balloonShrink
        case balloonOpacity
        case damageExpansion
        case damageShrink

        var value: CGFloat {
            switch self {
            case .enemy: return 0.4
            case .fire: return 0.3
            case .content: return 1.0
            case .balloonExpansion: return 0.2
            case .balloonShrink: return 0.2
            case .balloonOpacity: return 0.6
            case .damageExpansion: return 0.3
            case .damageShrink: return 0.15
            }
        }
    }

    // アニメーション開始タイミング
    // caseの順番は登場順にしている
    private enum AnimateTiming {
        case fire // 火の玉
        case mainBalloon // 吹き出し(メイン)
        case enemy // 敵
        case firstDamage // 100の位
        case secondDamage // 10の位
        case thirdDamage // 1の位
        case afterImageBalloon // 吹き出し(残像用)
        case content // 吹き出しとダメージ値全体

        var value: CGFloat {
            switch self {
            case .fire: return 0
            case .mainBalloon: return 0.2
            case .enemy: return 0.3
            case .firstDamage: return 0.45
            case .secondDamage: return 0.5
            case .thirdDamage: return 0.55
            case .afterImageBalloon: return 0.55
            case .content: return 0.8
            }
        }
    }

    var body: some View {
        VStack {
            ZStack {
                enemyView // 敵
                fireView // 火の玉
                contentView // ダメージバルーン
            }
            .frame(width: 276, height: 162)
            attackButton // 攻撃ボタン
        }
    }
}

// MARK: - method
extension DoxxxxxQuxxxDamageBalloonView {
    private func start() {
        task?.cancel()
        task = Task {
            reset()
            for await event in await CADisplayLink.events() {
                progress = event.timestamp - startTime

                // 敵
                animateEnemyView()

                // 火の玉
                animateFireView()

                // ダメージバルーン
                animateContentView()

                // 吹き出し
                animateMainBalloonView()
                animateAfterImageBalloonView()

                // ダメージ値
                firstDamageScale = calculateDamageScale(threshold: .firstDamage)
                secondDamageScale = calculateDamageScale(threshold: .secondDamage)
                thirdDamageScale = calculateDamageScale(threshold: .thirdDamage)
            }
        }
    }

    private func reset() {
        startTime = CACurrentMediaTime()
        // 各値を初期値にする(割愛)
    }
```

全体像がこんな感じで、残りは各要素のViewとアニメーションの処理を見ていきます。
(本当はアニメーション処理は全てextensionにいますが、以降は分けずに書きます)

やっべー、またファイル名でばれちゃうぜ。

### EnemyView

敵です。
かあいい死神くんのことです。
敵のアニメーションは

- 透明度をランダムにする

なので、必要情報は透明度のみですね。

都度透明度をrandomで取得し、その値を画像にセットすることで、ダメージ喰らってる感を演出しています。

```swift
struct DoxxxxxQuexxDamageBalloonView {
    @State private var enemyViewOpacity: CGFloat = 1

    var body: some View {
        VStack {
            ZStack {
                enemyView
                    .opacity(enemyViewOpacity) // 透明度
                fireView
                contentView
            }
            .frame(width: 276, height: 162)
            attackButton
        }
    }

    private var enemyView: some View {
        AsyncImage(url: URL.init(string: "https://2.bp.blogspot.com/-U3aAYTcSda8/W7WbOW2KcmI/AAAAAAABPPk/pu2T90JcZJoy6O_F8rQDXaOKgzpXRCVLQCLcBGAs/s400/halloween_chara2_shinigami.png")!) { image in
            image
                .resizable()
                .scaledToFit()
        } placeholder: {
            ProgressView()
        }
    }

    private func animateEnemyView() {
        if progress < AnimateTiming.enemy.value {
            // before animation
            enemyViewOpacity = 1
        } else if progress <= AnimateTiming.enemy.value + AnimateDuration.enemy.value {
            // animate
            enemyViewOpacity = .random(in: 0 ..< 1)
        } else {
            // after animation
            enemyViewOpacity = 1
        }
    }
}
```

### FireView

火の玉です。
魔法です。
火の玉のアニメーションは

- ちょい右側にいる自分の手元から敵に向かって火の玉が移動する
- 敵に当たったら消滅する

なので、必要情報は

- 透明度
- 位置(X,Y)

ですね。

位置移動に関しては、同秒数でEasingのみ変更しています。
Easingの変更だけで、同じ秒数でもこんなに見え方違うのかって改めて思いますね。

```swift
struct DoxxxxxQuexxDamageBalloonView {
    @State private var fireViewOpacity: CGFloat = 0
    @State private var fireViewX: CGFloat = 0
    @State private var fireViewY: CGFloat = 0

    var body: some View {
        VStack {
            ZStack {
                enemyView
                    .opacity(enemyViewOpacity)
                fireView
                    .offset(x: fireViewX, y: fireViewY) //　位置
                    .opacity(fireViewOpacity) // 透明度
                contentView
            }
            .frame(width: 276, height: 162)
            attackButton
        }
    }

    private var fireView: some View {
        ZStack {
            Circle()
                .fill(.red)
                .frame(width: 30, height: 30)
            Circle()
                .fill(.orange)
                .frame(width: 14, height: 14)
        }
    }

    private func animateFireView() {
        if progress < AnimateTiming.fire.value {
            // before animation
            fireViewOpacity = 1
            fireViewX = 0
            fireViewY = 0
        } else if progress <= AnimateTiming.fire.value + AnimateDuration.fire.value {
            // animate
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.fire.value) / AnimateDuration.fire.value
                return .minimum(elapsed, 1)
            }()
            fireViewOpacity = 1 - CustomEasing.easeIn.circ.progress(elapsed: elapsed)
            fireViewX = 50 - 50 * CustomEasing.easeIn.sine.progress(elapsed: elapsed)
            fireViewY = 50 - 50 * CustomEasing.easeIn.circ.progress(elapsed: elapsed)
        } else {
            // after animation
            fireViewOpacity = 0
            fireViewX = 0
            fireViewY = 0
        }
    }
}
```

### ダメージバルーン

大きな括りになってしまいますが、吹き出しとダメージ値からなるダメージバルーンです。
ここが一番動きがあるところですね。
ダメージバルーンのアニメーションは

- 吹き出しが飛び出る
- 吹き出しがおさまっていくと同時に100の位から数字が順に飛び出る
- 吹き出しがおさまりきるときに残像用の吹き出しを弾けさせる
- 最後に上に全体を飛ばしながら消す

なので、必要情報は

- ダメージバルーン
    - 透明度
    - 位置(X,Y)
- メイン吹き出し
    - 透明度
    - スケール
    - 位置(X,Y)
- 残像用吹き出し
    - 透明度
    - スケール
- ダメージ値
    - スケール

ですね。
結構多いですが、絡み合っているので一気に駆け抜けちゃいます。

```swift
struct DoxxxxxQuexxDamageBalloonView {
    // mainBalloonView
    @State private var mainBalloonViewOpacity: CGFloat = 0
    @State private var mainBalloonViewScale: CGFloat = 0
    @State private var mainBalloonViewX: CGFloat = 0
    @State private var mainBalloonViewY: CGFloat = 0
    // afterImageBalloonView
    @State private var afterImageBalloonViewOpacity: CGFloat = 0
    @State private var afterImageBalloonViewScale: CGFloat = 0
    // firstDamage
    @State private var firstDamageScale: CGFloat = 0
    // secondDamage
    @State private var secondDamageScale: CGFloat = 0
    // thirdDamage
    @State private var thirdDamageScale: CGFloat = 0

    var body: some View {
        VStack {
            ZStack {
                enemyView
                    .opacity(enemyViewOpacity)
                fireView
                    .offset(x: fireViewX, y: fireViewY)
                    .opacity(fireViewOpacity)
                contentView
                    .offset(x: contentViewX, y: contentViewY) //　位置
                    .opacity(contentViewOpacity) // 透明度
            }
            .frame(width: 276, height: 162)
            attackButton
        }
    }

    // ダメージバルーン
    private var contentView: some View {
        ZStack {
            balloonView
                .scaleEffect(afterImageBalloonViewScale)
                .opacity(afterImageBalloonViewOpacity)
            balloonView
                .offset(x: mainBalloonViewX, y: mainBalloonViewY)
                .scaleEffect(mainBalloonViewScale)
                .opacity(mainBalloonViewOpacity)
            damageAmountView
        }
    }

    // 吹き出し
    private var balloonView: some View {
        // 画像で作れば楽だろうな
        Path { path in
            path.addLines([
                .init(x: 118, y: 0),
                .init(x: 142, y: 27),
                .init(x: 176, y: 9),
                .init(x: 186, y: 28),
                .init(x: 235, y: 26),
                .init(x: 222, y: 54),
                .init(x: 264, y: 60),
                .init(x: 240, y: 80),
                .init(x: 276, y: 105),
                .init(x: 230, y: 108),
                .init(x: 237, y: 128),
                .init(x: 205, y: 126),
                .init(x: 208, y: 150),
                .init(x: 174, y: 140),
                .init(x: 165, y: 160),
                .init(x: 138, y: 143),
                .init(x: 112, y: 162),
                .init(x: 96, y: 140),
                .init(x: 52, y: 152),
                .init(x: 60, y: 130),
                .init(x: 22, y: 136),
                .init(x: 42, y: 110),
                .init(x: 8, y: 102),
                .init(x: 40, y: 85),
                .init(x: 4, y: 65),
                .init(x: 45, y: 60),
                .init(x: 30, y: 41),
                .init(x: 68, y: 45),
                .init(x: 55, y: 15),
                .init(x: 98, y: 32),
                .init(x: 118, y: 0)
            ])
        }
        .fill(RadialGradient(gradient: Gradient(colors: [.white, .yellow]), center: .center, startRadius: 1, endRadius: 80))
    }

    // ダメージ値
    private var damageAmountView: some View {
        HStack(spacing: -4) {
            Text("6")
                .scaleEffect(firstDamageScale) // それぞれにscaleEffectを設定
            Text("5")
                .scaleEffect(secondDamageScale)
            Text("3")
                .scaleEffect(thirdDamageScale)
        }
        .foregroundColor(.orange)
        .font(.system(size: 50, weight: .bold))
    }

    private func animateContentView() {
        if progress < AnimateTiming.content.value {
            // before animation
            contentViewOpacity = 1
            contentViewX = 0
            contentViewY = 0
        } else if progress <= AnimateTiming.content.value + AnimateDuration.content.value {
            // animate
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.content.value) / AnimateDuration.content.value
                return .minimum(elapsed, 1)
            }()
            contentViewOpacity = 1 - CustomEasing.easeIn.sine.progress(elapsed: elapsed)
            contentViewX = -2 *  CustomEasing.easeIn.quad.progress(elapsed: elapsed)
            contentViewY = -5 * CustomEasing.easeIn.quad.progress(elapsed: elapsed)
        } else {
            // after animation
            contentViewOpacity = 0
            contentViewX = -2
            contentViewY = -5
        }
    }

    private func animateMainBalloonView() {
        if progress < AnimateTiming.mainBalloon.value {
            // before animation
            mainBalloonViewOpacity = 0
            mainBalloonViewScale = 0.6
            mainBalloonViewX = 0
            mainBalloonViewY = 0
        } else if progress <= AnimateTiming.mainBalloon.value + AnimateDuration.balloonExpansion.value {
            // animate (拡大)
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.mainBalloon.value) / AnimateDuration.balloonExpansion.value
                return .minimum(elapsed, 1)
            }()
            mainBalloonViewOpacity = CustomEasing.easeOut.sine.progress(elapsed: elapsed)
            mainBalloonViewScale = CustomEasing.easeIn.quad.progress(elapsed: elapsed)
            mainBalloonViewX = -5 *  CustomEasing.easeIn.quad.progress(elapsed: elapsed)
            mainBalloonViewY = -30 * CustomEasing.easeIn.quad.progress(elapsed: elapsed)
        } else if progress <= AnimateTiming.mainBalloon.value + AnimateDuration.balloonExpansion.value + AnimateDuration.balloonShrink.value {
            // animate (縮小)
            let elapsed: CGFloat = {
                let elapsed = (progress - (AnimateTiming.mainBalloon.value + AnimateDuration.balloonExpansion.value)) / AnimateDuration.balloonShrink.value
                return .minimum(elapsed, 1)
            }()
            mainBalloonViewScale = 1 - 0.4 * CustomEasing.easeIn.quad.progress(elapsed: elapsed)
            mainBalloonViewX = -5 + 5 *  CustomEasing.easeIn.quad.progress(elapsed: elapsed)
            mainBalloonViewY = -30 + 30 * CustomEasing.easeIn.quad.progress(elapsed: elapsed)
        } else {
            // after animation
            mainBalloonViewOpacity = 1
            mainBalloonViewScale = 0.6
            mainBalloonViewX = 0
            mainBalloonViewY = 0
        }
    }

    private func animateAfterImageBalloonView() {
        if progress < AnimateTiming.afterImageBalloon.value {
            // before animation
            afterImageBalloonViewOpacity = 0
            afterImageBalloonViewScale = 0
        } else if progress <= AnimateTiming.afterImageBalloon.value + AnimateDuration.balloonOpacity.value {
            // animate
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.afterImageBalloon.value) / AnimateDuration.balloonOpacity.value
                return .minimum(elapsed, 1)
            }()
            afterImageBalloonViewOpacity = 1 - CustomEasing.easeIn.sine.progress(elapsed: elapsed)
            afterImageBalloonViewScale = CustomEasing.easeOut.circ.progress(elapsed: elapsed)
        } else {
            // after animation
            afterImageBalloonViewOpacity = 0
            afterImageBalloonViewScale = 1
        }
    }

    private func calculateDamageScale(threshold: AnimateTiming) -> CGFloat {
        if progress < threshold.value {
            // before animation
            return .zero
        } else if progress <= threshold.value + AnimateDuration.damageExpansion.value {
            // animate (拡大)
            let elapsed: CGFloat = {
                let elapsed = (progress - threshold.value) / AnimateDuration.damageExpansion.value
                return .minimum(elapsed, 1)
            }()
            return 2 * CustomEasing.easeIn.quart.progress(elapsed: elapsed)
        } else if progress <= threshold.value + AnimateDuration.damageExpansion.value + AnimateDuration.damageShrink.value {
            // animate (縮小)
            let elapsed: CGFloat = {
                let elapsed = (progress - (threshold.value + AnimateDuration.damageExpansion.value)) / AnimateDuration.damageShrink.value
                return .minimum(elapsed, 1)
            }()
            return 2 - CustomEasing.easeInOut.sine.progress(elapsed: elapsed)
        } else {
            // after animation
            return 1
        }
    }
}
```

長くなってしまいましたが、これで成果物のアニメーションを実装することができました。

## おわりに

SwiftUIとCADisplayLinkを駆使して、某世界線のダメージバルーンを作ってみました。
HPゲージの時もそうですが、**どういう風に作るか、完成形をイメージすること**がアニメーションを作る際には重要だなと感じます。
おおよそのイメージを作る、そこからチューニングしていくという流れになるのですが、チューニングにとても時間がかかります。(あとずっと見てると目がおかしくなってきたりします←)
ここをおろそかにするとアニメーションは一気にちゃっちくなってしまったりするので、**最初にどれだけイメージできているか**が成果物のクオリティに大きく影響してきます。(もちろん途中でこっちの方がいいんじゃない、となることもありますが、それは途中で頭に浮かぶイメージによりアップデートされるということ)
そしてそのイメージは、普段からゲームや映画、テレビ等様々なものを見て体験しているかで出来が大きく変わることだったりするので、やはり何気ない普段のインプットも大事です。正直そんなにいつもインプットするぞ！！！という目線であらゆるコンテンツを見ているわけではないですが、**ふと何かを作ろうとした時になんとなく思い出せる**、ぐらいに引き出しを多く持てるようにしています。
自分がイメージしていたものができあがるとめっちゃ嬉しいので、今回の記事を見て、あれ作ってみたいなあなんて思ったら一度作ってみたら気持ち良いかもしれませんね！