feat(live-room-like-heart): 하트가 가득 차면 폭죽처럼 터지는 애니메이션 반영

This commit is contained in:
Yu Sung
2025-11-05 18:01:32 +09:00
parent 95c2e992de
commit 34eed366bd
2 changed files with 197 additions and 7 deletions

View File

@@ -12,6 +12,19 @@ import Combine
import AgoraRtcKit
import AgoraRtmKit
struct BigHeartParticle: Identifiable {
let id: UUID
var x: CGFloat
var y: CGFloat
var vx: CGFloat
var vy: CGFloat
var opacity: Double
var scale: CGFloat
var rotation: Double
var life: Double // ()
var size: CGFloat // (pt)
}
final class LiveRoomViewModel: NSObject, ObservableObject {
private var agora: Agora = Agora.shared
@@ -213,6 +226,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
@Published var hearts: [Heart] = []
// Remote BIG-HEART () +
@Published var isShowRemoteBigHeart: Bool = false
@Published var remoteWaterProgress: CGFloat = 0
@Published var remoteWavePhase: CGFloat = 0
@Published var bigHeartParticles: [BigHeartParticle] = []
var signatureImageUrls = [String]()
var signatureList = [LiveRoomDonationResponse]()
var isShowSignatureImage = false
@@ -221,12 +240,19 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
var heartTimer: DispatchSourceTimer?
var periodicPlaybackTimer: DispatchSourceTimer?
// BIG HEART
var remoteWaterTimer: DispatchSourceTimer?
var bigHeartParticleTimer: DispatchSourceTimer?
var isAvailableLikeHeart = false
private var blockedMemberIdList = Set<Int>()
private var hasInvokedJoinChannel = false
// BIG_HEART :
private var suppressNextRemoteWaterFill = false
func getBlockedMemberIdList() {
userRepository.getBlockedMemberIdList()
.sink { result in
@@ -1937,7 +1963,15 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
self.addHeartMessage(nickname: nickname)
totalHeartCount += heartCount
if messageType == .BIG_HEART_DONATION {
// : ,
// (1)
self.suppressNextRemoteWaterFill = true
addBigHeartAnimation()
} else {
addHeart()
}
self.invalidateChat()
} else {
@@ -2049,6 +2083,136 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
heartTimer = nil
}
private func addBigHeartAnimation() {
// 1
if suppressNextRemoteWaterFill {
suppressNextRemoteWaterFill = false
spawnHeartExplosion()
startParticlesTimer()
return
}
// ( ): 1
startRemoteWaterFill(duration: 1.0) { [weak self] in
self?.spawnHeartExplosion()
self?.startParticlesTimer()
}
}
// MARK: - BIG HEART - Remote Water Fill
private func startRemoteWaterFill(duration: Double, completion: @escaping () -> Void) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.isShowRemoteBigHeart = true
self.remoteWaterProgress = 0
self.remoteWavePhase = 0
self.remoteWaterTimer?.cancel()
self.remoteWaterTimer = nil
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
let dt: Double = 1.0 / 60.0
var elapsed: Double = 0
timer.schedule(deadline: .now(), repeating: dt)
timer.setEventHandler { [weak self] in
guard let self = self else { return }
elapsed += dt
let p = min(max(elapsed / duration, 0), 1)
self.remoteWaterProgress = p
self.remoteWavePhase += 0.35
if p >= 1.0 {
self.remoteWaterTimer?.cancel()
self.remoteWaterTimer = nil
//
self.isShowRemoteBigHeart = false
self.remoteWaterProgress = 0
self.remoteWavePhase = 0
completion()
}
}
timer.resume()
self.remoteWaterTimer = timer
}
}
// MARK: - BIG HEART - Explosion Particles
private func spawnHeartExplosion() {
// (0,0)
// View #ff959a
var particles: [BigHeartParticle] = []
let count = Int.random(in: 20...35) // : 20~35
for i in 0..<count {
// +
let baseAngle = (Double(i) / Double(count)) * .pi * 2
let jitter = Double.random(in: -0.35...0.35)
let angle = baseAngle + jitter
// (px/s)
let speed = CGFloat.random(in: 220...360)
let vx = CGFloat(cos(angle)) * speed
var vy = CGFloat(sin(angle)) * speed
//
vy -= CGFloat.random(in: 120...220)
// : 30~80pt
let size = CGFloat.random(in: 20...65)
let scale: CGFloat = 1.0 // : 20~65 ( )
let life: Double = Double.random(in: 1.0...1.5)
let particle = BigHeartParticle(
id: UUID(),
x: 0,
y: 0,
vx: vx,
vy: vy,
opacity: 1.0,
scale: scale,
rotation: Double.random(in: 0...360),
life: life,
size: size
)
particles.append(particle)
}
DispatchQueue.main.async {
self.bigHeartParticles = particles
}
}
private func startParticlesTimer() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.bigHeartParticleTimer?.cancel()
self.bigHeartParticleTimer = nil
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 }
var next: [BigHeartParticle] = []
next.reserveCapacity(self.bigHeartParticles.count)
for var p in self.bigHeartParticles {
//
p.vy += gravity * 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
p.opacity = max(0, min(1, p.life / baseLife))
//
p.scale *= 0.995
if p.life > 0 && p.opacity > 0.01 {
next.append(p)
}
}
self.bigHeartParticles = next
if next.isEmpty {
self.bigHeartParticleTimer?.cancel()
self.bigHeartParticleTimer = nil
}
}
timer.resume()
self.bigHeartParticleTimer = timer
}
}
private func invalidateChat() {
messageChangeFlag.toggle()
if messages.count > 100 {
@@ -2261,7 +2425,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
} else if decoded.type == .BIG_HEART_DONATION {
self.addHeartMessage(nickname: nickname)
self.totalHeartCount += decoded.can
self.addHeart()
self.addBigHeartAnimation()
}
} catch {
}

View File

@@ -759,11 +759,37 @@ struct LiveRoomViewV2: View {
}
}
.overlay(alignment: .center) {
ZStack {
// ( )
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
.frame(width: 280, height: 210) // 4:3
.frame(width: 280, height: 210)
.allowsHitTesting(false)
.opacity(showWaterHeart ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
// ( ) - 1
WaterHeartView(progress: viewModel.remoteWaterProgress,
show: viewModel.isShowRemoteBigHeart,
phase: viewModel.remoteWavePhase)
.frame(width: 280, height: 210)
.allowsHitTesting(false)
//
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
// ( , #ff959a)
ZStack {
ForEach(viewModel.bigHeartParticles) { p in
HeartShape()
.fill(Color(hex: "ff959a"))
.frame(width: p.size * p.scale, height: p.size * p.scale)
.rotationEffect(.degrees(p.rotation))
.offset(x: p.x, y: p.y)
.opacity(p.opacity)
.allowsHitTesting(false)
}
}
}
}
.onReceive(heartWaveTimer) { _ in
guard isLongPressingHeart else { return }