feat(live-room): 왕하트 애니메이션 수정
This commit is contained in:
@@ -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초 랜덤으로 0→1 스케일 업
|
||||||
|
@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) // 상한 220→500로 확대하여 낙하 속도 분산 강화
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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초 연출
|
// 원격 수신 시(또는 공통 트리거) 물 채우기 하트 - 0→1 스케일 업(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)
|
||||||
|
|||||||
Reference in New Issue
Block a user