diff --git a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift index c25b00d..746afec 100644 --- a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift +++ b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift @@ -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() 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 - addHeart() + + if messageType == .BIG_HEART_DONATION { + // 로컬 발신: 수신 알림 직후 즉시 폭발 연출만 보이고, + // 물 채움(1초)은 발신 측에서는 생략 + self.suppressNextRemoteWaterFill = true + addBigHeartAnimation() + } else { + addHeart() + } self.invalidateChat() } else { @@ -2048,6 +2082,136 @@ final class LiveRoomViewModel: NSObject, ObservableObject { heartTimer?.cancel() 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.. 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() @@ -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 { } diff --git a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift index c99b6ae..359b040 100644 --- a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift +++ b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift @@ -759,11 +759,37 @@ struct LiveRoomViewV2: View { } } .overlay(alignment: .center) { - WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase) - .frame(width: 280, height: 210) // 4:3 비율 유지 - .allowsHitTesting(false) - .opacity(showWaterHeart ? 1 : 0) - .animation(.easeInOut(duration: 0.2), value: showWaterHeart) + ZStack { + // 로컬(롱프레스 중) 물 채우기 하트 + WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase) + .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 }