feat(live-room-like-heart): 하트가 가득 차면 폭죽처럼 터지는 애니메이션 반영
This commit is contained in:
@@ -12,6 +12,19 @@ import Combine
|
|||||||
import AgoraRtcKit
|
import AgoraRtcKit
|
||||||
import AgoraRtmKit
|
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 {
|
final class LiveRoomViewModel: NSObject, ObservableObject {
|
||||||
|
|
||||||
private var agora: Agora = Agora.shared
|
private var agora: Agora = Agora.shared
|
||||||
@@ -213,6 +226,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
|
|
||||||
@Published var hearts: [Heart] = []
|
@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 signatureImageUrls = [String]()
|
||||||
var signatureList = [LiveRoomDonationResponse]()
|
var signatureList = [LiveRoomDonationResponse]()
|
||||||
var isShowSignatureImage = false
|
var isShowSignatureImage = false
|
||||||
@@ -221,12 +240,19 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
var heartTimer: DispatchSourceTimer?
|
var heartTimer: DispatchSourceTimer?
|
||||||
var periodicPlaybackTimer: DispatchSourceTimer?
|
var periodicPlaybackTimer: DispatchSourceTimer?
|
||||||
|
|
||||||
|
// BIG HEART 관련 타이머
|
||||||
|
var remoteWaterTimer: DispatchSourceTimer?
|
||||||
|
var bigHeartParticleTimer: DispatchSourceTimer?
|
||||||
|
|
||||||
var isAvailableLikeHeart = false
|
var isAvailableLikeHeart = false
|
||||||
|
|
||||||
private var blockedMemberIdList = Set<Int>()
|
private var blockedMemberIdList = Set<Int>()
|
||||||
|
|
||||||
private var hasInvokedJoinChannel = false
|
private var hasInvokedJoinChannel = false
|
||||||
|
|
||||||
|
// 로컬 BIG_HEART 발신자: 원격 물 채움 연출 억제 플래그
|
||||||
|
private var suppressNextRemoteWaterFill = false
|
||||||
|
|
||||||
func getBlockedMemberIdList() {
|
func getBlockedMemberIdList() {
|
||||||
userRepository.getBlockedMemberIdList()
|
userRepository.getBlockedMemberIdList()
|
||||||
.sink { result in
|
.sink { result in
|
||||||
@@ -1937,7 +1963,15 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
self.addHeartMessage(nickname: nickname)
|
self.addHeartMessage(nickname: nickname)
|
||||||
|
|
||||||
totalHeartCount += heartCount
|
totalHeartCount += heartCount
|
||||||
addHeart()
|
|
||||||
|
if messageType == .BIG_HEART_DONATION {
|
||||||
|
// 로컬 발신: 수신 알림 직후 즉시 폭발 연출만 보이고,
|
||||||
|
// 물 채움(1초)은 발신 측에서는 생략
|
||||||
|
self.suppressNextRemoteWaterFill = true
|
||||||
|
addBigHeartAnimation()
|
||||||
|
} else {
|
||||||
|
addHeart()
|
||||||
|
}
|
||||||
|
|
||||||
self.invalidateChat()
|
self.invalidateChat()
|
||||||
} else {
|
} else {
|
||||||
@@ -2049,6 +2083,136 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
heartTimer = nil
|
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() {
|
private func invalidateChat() {
|
||||||
messageChangeFlag.toggle()
|
messageChangeFlag.toggle()
|
||||||
if messages.count > 100 {
|
if messages.count > 100 {
|
||||||
@@ -2261,7 +2425,7 @@ extension LiveRoomViewModel: AgoraRtmClientDelegate {
|
|||||||
} else if decoded.type == .BIG_HEART_DONATION {
|
} else if decoded.type == .BIG_HEART_DONATION {
|
||||||
self.addHeartMessage(nickname: nickname)
|
self.addHeartMessage(nickname: nickname)
|
||||||
self.totalHeartCount += decoded.can
|
self.totalHeartCount += decoded.can
|
||||||
self.addHeart()
|
self.addBigHeartAnimation()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,11 +759,37 @@ struct LiveRoomViewV2: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .center) {
|
.overlay(alignment: .center) {
|
||||||
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
|
ZStack {
|
||||||
.frame(width: 280, height: 210) // 4:3 비율 유지
|
// 로컬(롱프레스 중) 물 채우기 하트
|
||||||
.allowsHitTesting(false)
|
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
|
||||||
.opacity(showWaterHeart ? 1 : 0)
|
.frame(width: 280, height: 210)
|
||||||
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
|
.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
|
.onReceive(heartWaveTimer) { _ in
|
||||||
guard isLongPressingHeart else { return }
|
guard isLongPressingHeart else { return }
|
||||||
|
|||||||
Reference in New Issue
Block a user