はじめに
さて、世には体力という概念があるゲームがさまざまありますね。
そんな中、多くの人が通ったんじゃないかなーと思うものを参考に、何かを思わせる体力ゲージをSwiftUIで作ってみました。
この記事は、気づいた人からああ、あれか!と楽しめるタイプの記事となっております。
成果物
何を彷彿させるかは置いといて、一旦AとBとします。
最後に答え合わせ・・・は気が向いたらします。
A
B
実装
A
こちらは、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
}
}
}
}
で、ついに本題。
ストリームでどんどんtimestampを更新して、そのtimestampから進捗を算出。
その進捗をeaseOutCircに乗せて、その時点でのHPゲージおよび数字を算出して、Viewに反映する、と。
フレームごとに算出しているから、途中時点での色変更も容易にできちゃう。
import SwiftUI
struct PoxxxxxHitPointView: View {
@State private var percentage: CGFloat = 1.0
@State private var fillColor: Color = .green
@State private var startTime: CFTimeInterval = .zero
@State private var progress: CFTimeInterval = .zero
@State private var duration: CGFloat = 5
@State private var maxHP: Int = 315
@State private var currentHP: Int = 315
@State private var task: Task<Void, Never>?
var body: some View {
VStack {
Capsule()
.fill(.gray.opacity(0.2))
.frame(width: 300, height: 8)
.overlay(alignment: .leading) {
Rectangle()
.fill(fillColor)
.frame(width: 300 * percentage)
}
.cornerRadius(4)
Text("\(currentHP)/\(maxHP)")
.font(.footnote.bold())
.kerning(2)
.padding(.bottom, 24)
Button(action: {
start()
}, label: {
Text("Start")
.foregroundColor(.white)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.background(Color.blue)
.cornerRadius(20)
})
Button(action: {
stop()
}, label: {
Text("Stop")
.foregroundColor(.white)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.background(Color.blue)
.cornerRadius(20)
})
}
}
private func start() {
task?.cancel()
task = Task {
reset()
for await event in await CADisplayLink.events() {
// 進捗を算出
progress = (event.timestamp - startTime) / duration
// progressが1を超えたら終了
if progress > 1 {
stop()
}
// ゲージの残り%を算出
percentage = 1 - CustomEasing.easeOut.circ.progress(elapsed: progress)
// 残りHPを算出
currentHP = Int(CGFloat(maxHP) * (1.0 - (CustomEasing.easeOut.circ.progress(elapsed: progress))))
// percentageに合わせて、色を変更
fillColor = {
if percentage > 0.5 {
return .green // 「元気」色
} else if percentage > 0.2 {
return .yellow // 「そろそろやばい」色
} else {
return .red // 「いや、もうまじでやばい」色
}
}()
}
}
}
private func stop() {
task?.cancel()
task = nil
}
private func reset() {
startTime = CACurrentMediaTime()
progress = .zero
currentHP = maxHP
}
}
struct PoxxxxxHitPointView_Previews: PreviewProvider {
static var previews: some View {
PoxxxxxHitPointView()
.previewLayout(.sizeThatFits)
}
}
まぁ、答えは言わないけど、Viewの名前でわかっちゃうかな〜〜〜〜
B
続いて、Bのアニメーション。
こちらは技術自体はシンプルに、Keyframeチックにしてみようかなーという思いでいきます。
って思ったんですが、結果結構違うことになり、パンチごとに(??)ダメージを喰らう感じに、percentageの配列を持つ形にします。で、ボタンタップの度に要素をずらす。
緑のゲージと赤のゲージをそれぞれ用意し、緑と赤のduration(アニメーションの時間)を変更することで、即座に残りHPがわかりつつ減った感も伝わるアニメーションが仕上がります。
import SwiftUI
struct StxxxxFixxxxxHitPointView: View {
@State private var counter: Int = 0
@State private var percentage: CGFloat = 1.0
private var percentages: [CGFloat] = [1, 0.9, 0.7, 0.2, 0]
var body: some View {
VStack {
Capsule()
.fill(.gray.opacity(0.2))
.frame(width: 300, height: 8)
.overlay(alignment: .leading) {
Rectangle()
.fill(.red)
.frame(width: 300 * percentage)
.animation(CustomEasing.easeOut.circ.timingCurve(duration: 2.5), value: percentage) // durationを変更することで追っていく感じに
}
.overlay(alignment: .leading) {
Rectangle()
.fill(.green)
.frame(width: 300 * percentage)
.animation(CustomEasing.easeOut.circ.timingCurve(duration: 0.4), value: percentage) // こっちはほぼ即座に(ゲームでは即時がいいと思うけど、まぁここはデモということで多少遊ばせてる)
}
.cornerRadius(4)
.padding(.bottom, 24)
Button(action: {
attack()
}, label: {
Text("Attack")
.foregroundColor(.white)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.background(Color.blue)
.cornerRadius(20)
})
Button(action: {
reset()
}, label: {
Text("Reset")
.foregroundColor(.white)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.background(Color.blue)
.cornerRadius(20)
})
}
}
private func attack() {
guard counter < percentages.count - 1 else { return } // ほんまはButtonのdisabledを制御して押せないUIにするのが正しいけど今回は省く
counter += 1
setPercentage()
}
private func reset() {
counter = 0
setPercentage()
}
private func setPercentage() {
percentage = percentages[counter]
}
}
struct StxxxxFixxxxxHitPointView_Previews: PreviewProvider {
static var previews: some View {
StxxxxFixxxxxHitPointView()
.previewLayout(.sizeThatFits)
}
}
こっちもわかっちゃう人にはわかっちゃうな〜〜〜〜〜
※ちなみにどっちも、ナンバリング等でアニメーション違うので、ある時点でのぐらいで認識してもらえると
まとめ
気が向かなかったので答えは書きませんが、動きとテレパシーで何の世界の体力ゲージか伝わってたら良いなと思います。
プロダクトコードにするならもっとさまざま工夫必要ですが、
こういった体力ゲージアニメーションも、SwiftUI、結構さくっと実装できるな〜という感想。(根本、進捗から算出して〜っていうロジック自体はUIKit時代からやってきてるからかもしれんけど)
余談
SwiftUI周りの記事もっと書きたいのあるから、どんどん書いていきたいと思うのでちゃんねるとうr・・・じゃなくて乞うご期待!←