feat(live-room-like-heart): 폭발 후 하트 비/우박 애니메이션 반영

This commit is contained in:
Yu Sung
2025-11-05 19:22:06 +09:00
parent 3cbac1280e
commit a4c5a790fe

View File

@@ -8,6 +8,7 @@
import Foundation
import Moya
import Combine
import UIKit
import AgoraRtcKit
import AgoraRtmKit
@@ -23,6 +24,8 @@ struct BigHeartParticle: Identifiable {
var rotation: Double
var life: Double // ()
var size: CGFloat // (pt)
var isRain: Bool //
var gravityScale: CGFloat // ( )
}
final class LiveRoomViewModel: NSObject, ObservableObject {
@@ -232,6 +235,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
@Published var remoteWavePhase: CGFloat = 0
@Published var bigHeartParticles: [BigHeartParticle] = []
// / ( )
private var lastExplosionCount: Int = 0
private var lastExplosionSizes: [CGFloat] = []
// ()
private var shouldSpawnRainAfterExplosionEnds: Bool = false
var signatureImageUrls = [String]()
var signatureList = [LiveRoomDonationResponse]()
var isShowSignatureImage = false
@@ -2149,9 +2158,9 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
var vy = CGFloat(sin(angle)) * speed
//
vy -= CGFloat.random(in: 120...220)
// : 30~80pt
// : 20~65pt
let size = CGFloat.random(in: 20...65)
let scale: CGFloat = 1.0 // : 20~65 ( )
let scale: CGFloat = 1.0 // ( )
let life: Double = Double.random(in: 1.0...1.5)
let particle = BigHeartParticle(
id: UUID(),
@@ -2163,45 +2172,119 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
scale: scale,
rotation: Double.random(in: 0...360),
life: life,
size: size
size: size,
isRain: false,
gravityScale: 1.0
)
particles.append(particle)
}
// : /
self.lastExplosionCount = count
self.lastExplosionSizes = particles.map { $0.size }
DispatchQueue.main.async {
self.bigHeartParticles = particles
}
//
self.shouldSpawnRainAfterExplosionEnds = true
}
// MARK: - BIG HEART - Rain Particles (after explosion)
private func spawnHeartRainFromLastExplosion() -> [BigHeartParticle] {
let count = max(0, self.lastExplosionCount)
guard count > 0 else { return [] }
let bounds = UIScreen.main.bounds
let startYBase = -bounds.height * 0.5 - 40 // ()
var rains: [BigHeartParticle] = []
rains.reserveCapacity(count)
for i in 0..<count {
// +
let ratio = (CGFloat(i) + 0.5) / CGFloat(count)
var x = (ratio - 0.5) * bounds.width
x += CGFloat.random(in: -20...20)
// ( )
let size: CGFloat
if i < self.lastExplosionSizes.count {
size = self.lastExplosionSizes[i]
} else {
size = CGFloat.random(in: 20...65)
}
let vx = CGFloat.random(in: -60...60)
let vy = CGFloat.random(in: 10...500) // 220500
let startY = startYBase + CGFloat.random(in: -80...20) //
let sizeNorm = max(0, min(1, (size - 30) / 120))
var gScale = 0.8 + 0.8 * sizeNorm // 0.8..1.6 (size )
gScale += CGFloat.random(in: -0.15...0.15) //
// variation 1.3x: (1.0) 1.3
gScale = 1.0 + (gScale - 1.0) * CGFloat(1.3)
gScale = max(0.5, min(1.5, gScale))
let p = BigHeartParticle(
id: UUID(),
x: x,
y: startY,
vx: vx,
vy: vy,
opacity: 1.0,
scale: 1.0,
rotation: Double.random(in: 0...360),
life: 4.0, // 4
size: size,
isRain: true,
gravityScale: gScale
)
rains.append(p)
}
return rains
}
private func startParticlesTimer() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.bigHeartParticleTimer?.cancel()
self.bigHeartParticleTimer = nil
//
guard self.bigHeartParticleTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
let dt: Double = 1.0 / 60.0
let gravity: CGFloat = 600 // px/s^2
timer.schedule(deadline: .now(), repeating: dt)
timer.setEventHandler { [weak self] in
guard let self = self else { return }
// ( )
let hadExplosionBefore = self.bigHeartParticles.contains(where: { !$0.isRain })
var next: [BigHeartParticle] = []
next.reserveCapacity(self.bigHeartParticles.count)
// ( )
let bounds = UIScreen.main.bounds
let floorY = bounds.height * 0.5 - 60 //
for var p in self.bigHeartParticles {
//
p.vy += gravity * CGFloat(dt)
p.vy += gravity * p.gravityScale * CGFloat(dt)
p.x += p.vx * CGFloat(dt)
p.y += p.vy * CGFloat(dt)
// ( )
p.rotation += Double((abs(p.vx) + abs(p.vy)) * 0.02 * CGFloat(dt))
// /
// / (/ )
p.life -= dt
let baseLife: Double = 1.2 // opacity
let baseLife: Double = p.isRain ? 4.0 : 1.2
p.opacity = max(0, min(1, p.life / baseLife))
//
p.scale *= 0.995
//
if p.isRain && p.y >= floorY {
continue
}
if p.life > 0 && p.opacity > 0.01 {
next.append(p)
}
}
// ( )
let hasExplosionAfter = next.contains(where: { !$0.isRain })
// :
if hadExplosionBefore && !hasExplosionAfter && self.shouldSpawnRainAfterExplosionEnds {
let rains = self.spawnHeartRainFromLastExplosion()
if !rains.isEmpty {
next.append(contentsOf: rains)
}
self.shouldSpawnRainAfterExplosionEnds = false
}
self.bigHeartParticles = next
if next.isEmpty {
self.bigHeartParticleTimer?.cancel()