はじめに
さて、お気づきの方もいるかもしれませんが、そうです、あの記事の続編(なんちゃって続編)です。
以前、体力ゲージを作成して、2つの世界線にリスペクトを示した、例のやつです。
今回もまた新たな世界線にリスペクト示しに来ました、と。
ゲームでよくある、ダメージ与えた時の演出。
いろんな表現がありますね。
RPGでは、主人公is自分な感じで、やっぱり気持ちよくダメージ与えたった感ほしいじゃないですか。
ってことで、あの世界線のダメージバルーン(なんて呼ぶかはわからないが、一旦ここではそう呼ぶ)を作ってみました!
成果物
デモ作った時はばちばちにその世界線の敵画像を使ってやっていたので、非常にココロオドル開発だったわけですが、できあがりに気づいたんです。
・・・使っちゃだめじゃね?って。
そう思わせてしまい記事に集中してもらえないのも嫌だし、今回はみんな大好きいらすとやから画像を拝借しました。
幸いかあいい死神くんがいたので、この子にダメージ与えたいと思います。
さて、みなさんは魔法使いです。
死神に炎の魔法を使いましょう。
○ラでもファ○アでも、各々好きな名前でいったりましょう!
ぱぁぁぁぁぁぁぁぁぁぁぁんっっっっ!!!!!
はい、では、実装の方を見ていきます。
実装
こちらは、例によって例のごとく、CADisplayLinkを使用したアニメーションになります。
約束事のように一応書いておくので、興味があったら中身覗いてください。
※CADisplayLinkについては過去にも記事を書いているので説明は省きます
※AsyncStreamを使用するところはここを参考にしました
本題じゃないので、折りたたんでおく
詳細
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)
}
}
}
で、EasingをSwiftUIで使えるように定義したもの
(長いのでこちらも)
詳細
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
}
}
}
}
それでは、今回使った要素それぞれについて見ていきます。
共通
まずは共通的に必要なものと、全体構成を。
登場人物
- 敵 (enemy)
- 火の玉 (fire)
- ダメージバルーン (content)
- 吹き出し (balloon)
- ダメージ値 (damage)
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で取得し、その値を画像にセットすることで、ダメージ喰らってる感を演出しています。
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の変更だけで、同じ秒数でもこんなに見え方違うのかって改めて思いますね。
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)
- 残像用吹き出し
- 透明度
- スケール
- ダメージ値
- スケール
ですね。
結構多いですが、絡み合っているので一気に駆け抜けちゃいます。
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ゲージの時もそうですが、どういう風に作るか、完成形をイメージすることがアニメーションを作る際には重要だなと感じます。
おおよそのイメージを作る、そこからチューニングしていくという流れになるのですが、チューニングにとても時間がかかります。(あとずっと見てると目がおかしくなってきたりします←)
ここをおろそかにするとアニメーションは一気にちゃっちくなってしまったりするので、最初にどれだけイメージできているかが成果物のクオリティに大きく影響してきます。(もちろん途中でこっちの方がいいんじゃない、となることもありますが、それは途中で頭に浮かぶイメージによりアップデートされるということ)
そしてそのイメージは、普段からゲームや映画、テレビ等様々なものを見て体験しているかで出来が大きく変わることだったりするので、やはり何気ない普段のインプットも大事です。正直そんなにいつもインプットするぞ!!!という目線であらゆるコンテンツを見ているわけではないですが、ふと何かを作ろうとした時になんとなく思い出せる、ぐらいに引き出しを多く持てるようにしています。
自分がイメージしていたものができあがるとめっちゃ嬉しいので、今回の記事を見て、あれ作ってみたいなあなんて思ったら一度作ってみたら気持ち良いかもしれませんね!