feat(live-room): 왕하트 애니메이션 수정

This commit is contained in:
Yu Sung
2025-11-17 17:50:38 +09:00
parent 6cd0e86308
commit 31319e4292
2 changed files with 76 additions and 41 deletions

View File

@@ -9,6 +9,7 @@ import Foundation
import Moya import Moya
import Combine import Combine
import UIKit import UIKit
import SwiftUI
import AgoraRtcKit import AgoraRtcKit
import AgoraRtmKit import AgoraRtmKit
@@ -238,6 +239,8 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
@Published var isShowRemoteBigHeart: Bool = false @Published var isShowRemoteBigHeart: Bool = false
@Published var remoteWaterProgress: CGFloat = 0 @Published var remoteWaterProgress: CGFloat = 0
@Published var remoteWavePhase: CGFloat = 0 @Published var remoteWavePhase: CGFloat = 0
// (0~1): 1.0~1.5 01
@Published var remoteHeartScale: CGFloat = 1.0
@Published var bigHeartParticles: [BigHeartParticle] = [] @Published var bigHeartParticles: [BigHeartParticle] = []
// / ( ) // / ( )
@@ -245,6 +248,8 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
private var lastExplosionSizes: [CGFloat] = [] private var lastExplosionSizes: [CGFloat] = []
// () // ()
private var shouldSpawnRainAfterExplosionEnds: Bool = false private var shouldSpawnRainAfterExplosionEnds: Bool = false
// () ( 1 + 6 = 7)
private var pendingExplosionBursts: Int = 0
var signatureImageUrls = [String]() var signatureImageUrls = [String]()
var signatureList = [LiveRoomDonationResponse]() var signatureList = [LiveRoomDonationResponse]()
@@ -2102,29 +2107,34 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} }
private func addBigHeartAnimation() { private func addBigHeartAnimation() {
// 1 // 1
if suppressNextRemoteWaterFill { if suppressNextRemoteWaterFill {
suppressNextRemoteWaterFill = false suppressNextRemoteWaterFill = false
spawnHeartExplosion() scheduleExplosionBursts()
startParticlesTimer() startParticlesTimer()
return return
} }
// : (1) . // : 0 0.5
// (0.15) .
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.remoteWaterTimer?.cancel() self.remoteWaterTimer?.cancel()
self.remoteWaterTimer = nil self.remoteWaterTimer = nil
self.remoteWavePhase = 0 self.remoteWavePhase = 0
self.remoteWaterProgress = 1.0 self.remoteWaterProgress = 1.0
self.remoteHeartScale = 0.0
self.isShowRemoteBigHeart = true self.isShowRemoteBigHeart = true
DEBUG_LOG("BIG_HEART: show filled heart, then explode after 0.30s") let growDuration = Double.random(in: 1.0...1.5)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.30) { [weak self] in withAnimation(.easeOut(duration: growDuration)) {
self.remoteHeartScale = 1.0
}
DEBUG_LOG("BIG_HEART: scale-up \(growDuration)s then sequential 7 bursts")
DispatchQueue.main.asyncAfter(deadline: .now() + growDuration) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.isShowRemoteBigHeart = false self.isShowRemoteBigHeart = false
self.remoteWaterProgress = 0 self.remoteWaterProgress = 0
self.remoteWavePhase = 0 self.remoteWavePhase = 0
self.spawnHeartExplosion() self.remoteHeartScale = 1.0
self.scheduleExplosionBursts()
self.startParticlesTimer() self.startParticlesTimer()
} }
} }
@@ -2165,11 +2175,11 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} }
// MARK: - BIG HEART - Explosion Particles // MARK: - BIG HEART - Explosion Particles
private func spawnHeartExplosion() { private func spawnHeartExplosion(origin: CGPoint = .zero) {
// (0,0) // origin
// View #ff959a let explosionDuration: Double = 0.7 // 1
var particles: [BigHeartParticle] = [] var particles: [BigHeartParticle] = []
let count = Int.random(in: 20...35) // : 20~35 let count = Int.random(in: 20...35) // 20~35
for i in 0..<count { for i in 0..<count {
// + // +
let baseAngle = (Double(i) / Double(count)) * .pi * 2 let baseAngle = (Double(i) / Double(count)) * .pi * 2
@@ -2184,11 +2194,11 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
// : 20~65pt // : 20~65pt
let size = CGFloat.random(in: 20...65) let size = CGFloat.random(in: 20...65)
let scale: CGFloat = 1.0 // ( ) let scale: CGFloat = 1.0 // ( )
let life: Double = Double.random(in: 1.0...1.5) let life: Double = explosionDuration
let particle = BigHeartParticle( let particle = BigHeartParticle(
id: UUID(), id: UUID(),
x: 0, x: origin.x,
y: 0, y: origin.y,
vx: vx, vx: vx,
vy: vy, vy: vy,
opacity: 1.0, opacity: 1.0,
@@ -2201,43 +2211,66 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
) )
particles.append(particle) particles.append(particle)
} }
// : / // ( )
self.lastExplosionCount = count self.lastExplosionCount = count
self.lastExplosionSizes = particles.map { $0.size } self.lastExplosionSizes = particles.map { $0.size }
DispatchQueue.main.async { DispatchQueue.main.async {
self.bigHeartParticles = particles self.bigHeartParticles.append(contentsOf: particles)
} }
// }
/// 0.7 7( 1 + 6)
private func scheduleExplosionBursts() {
let bursts = 7
let interval: Double = 0.7
self.pendingExplosionBursts = bursts
self.shouldSpawnRainAfterExplosionEnds = true self.shouldSpawnRainAfterExplosionEnds = true
let bounds = UIScreen.main.bounds
let margin: CGFloat = 80
for i in 0..<bursts {
let delay = Double(i) * interval
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self = self else { return }
let origin: CGPoint
if i == 0 {
origin = .zero //
} else {
let minX = -bounds.width * 0.5 + margin
let maxX = bounds.width * 0.5 - margin
let minY = -bounds.height * 0.5 + margin
let maxY = bounds.height * 0.5 - margin
origin = CGPoint(x: CGFloat.random(in: minX...maxX),
y: CGFloat.random(in: minY...maxY))
}
self.spawnHeartExplosion(origin: origin)
self.pendingExplosionBursts = max(0, self.pendingExplosionBursts - 1)
// ()
if self.bigHeartParticleTimer == nil {
self.startParticlesTimer()
}
}
}
} }
// MARK: - BIG HEART - Rain Particles (after explosion) // MARK: - BIG HEART - Rain Particles (after explosion)
private func spawnHeartRainFromLastExplosion() -> [BigHeartParticle] { private func spawnHeartRainFromLastExplosion() -> [BigHeartParticle] {
let count = max(0, self.lastExplosionCount) // : 80~100
guard count > 0 else { return [] } let count = Int.random(in: 80...100)
let bounds = UIScreen.main.bounds let bounds = UIScreen.main.bounds
let startYBase = -bounds.height * 0.5 - 40 // () let startYBase = -bounds.height * 0.5 - 40 // ()
var rains: [BigHeartParticle] = [] var rains: [BigHeartParticle] = []
rains.reserveCapacity(count) rains.reserveCapacity(count)
for i in 0..<count { for _ in 0..<count {
// + //
let ratio = (CGFloat(i) + 0.5) / CGFloat(count) let x = CGFloat.random(in: -bounds.width * 0.5...bounds.width * 0.5)
var x = (ratio - 0.5) * bounds.width //
x += CGFloat.random(in: -20...20) let size = CGFloat.random(in: 18...60)
// ( )
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 vx = CGFloat.random(in: -60...60)
let vy = CGFloat.random(in: 10...500) // 220500 let vy = CGFloat.random(in: 120...520)
let startY = startYBase + CGFloat.random(in: -80...20) // let startY = startYBase + CGFloat.random(in: -80...20)
let sizeNorm = max(0, min(1, (size - 30) / 120)) let sizeNorm = max(0, min(1, (size - 30) / 120))
var gScale = 0.8 + 0.8 * sizeNorm // 0.8..1.6 (size ) var gScale = 0.8 + 0.8 * sizeNorm
gScale += CGFloat.random(in: -0.15...0.15) // 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 = 1.0 + (gScale - 1.0) * CGFloat(1.3)
gScale = max(0.5, min(1.5, gScale)) gScale = max(0.5, min(1.5, gScale))
let p = BigHeartParticle( let p = BigHeartParticle(
@@ -2286,7 +2319,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
p.rotation += Double((abs(p.vx) + abs(p.vy)) * 0.02 * CGFloat(dt)) p.rotation += Double((abs(p.vx) + abs(p.vy)) * 0.02 * CGFloat(dt))
// / (/ ) // / (/ )
p.life -= dt p.life -= dt
let baseLife: Double = p.isRain ? 4.0 : 1.2 let baseLife: Double = p.isRain ? 4.0 : 0.7
p.opacity = max(0, min(1, p.life / baseLife)) p.opacity = max(0, min(1, p.life / baseLife))
// //
p.scale *= 0.995 p.scale *= 0.995
@@ -2300,8 +2333,8 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} }
// ( ) // ( )
let hasExplosionAfter = next.contains(where: { !$0.isRain }) let hasExplosionAfter = next.contains(where: { !$0.isRain })
// : // :
if hadExplosionBefore && !hasExplosionAfter && self.shouldSpawnRainAfterExplosionEnds { if hadExplosionBefore && !hasExplosionAfter && self.shouldSpawnRainAfterExplosionEnds && self.pendingExplosionBursts == 0 {
let rains = self.spawnHeartRainFromLastExplosion() let rains = self.spawnHeartRainFromLastExplosion()
if !rains.isEmpty { if !rains.isEmpty {
next.append(contentsOf: rains) next.append(contentsOf: rains)
@@ -2309,7 +2342,8 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
self.shouldSpawnRainAfterExplosionEnds = false self.shouldSpawnRainAfterExplosionEnds = false
} }
self.bigHeartParticles = next self.bigHeartParticles = next
if next.isEmpty { // : ,
if next.isEmpty && self.pendingExplosionBursts == 0 {
self.bigHeartParticleTimer?.cancel() self.bigHeartParticleTimer?.cancel()
self.bigHeartParticleTimer = nil self.bigHeartParticleTimer = nil
} }

View File

@@ -767,11 +767,12 @@ struct LiveRoomViewV2: View {
.opacity(showWaterHeart ? 1 : 0) .opacity(showWaterHeart ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showWaterHeart) .animation(.easeInOut(duration: 0.2), value: showWaterHeart)
// ( ) - 1 // ( ) - 01 (0.5s)
WaterHeartView(progress: viewModel.remoteWaterProgress, WaterHeartView(progress: viewModel.remoteWaterProgress,
show: viewModel.isShowRemoteBigHeart, show: viewModel.isShowRemoteBigHeart,
phase: viewModel.remoteWavePhase) phase: viewModel.remoteWavePhase)
.frame(width: 210, height: 210) .frame(width: 210, height: 210)
.scaleEffect(viewModel.remoteHeartScale)
.allowsHitTesting(false) .allowsHitTesting(false)
// //
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0) .opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)