feat(live-room-like-heart): 폭발 후 하트 비/우박 애니메이션 반영
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Moya
|
import Moya
|
||||||
import Combine
|
import Combine
|
||||||
|
import UIKit
|
||||||
|
|
||||||
import AgoraRtcKit
|
import AgoraRtcKit
|
||||||
import AgoraRtmKit
|
import AgoraRtmKit
|
||||||
@@ -23,6 +24,8 @@ struct BigHeartParticle: Identifiable {
|
|||||||
var rotation: Double
|
var rotation: Double
|
||||||
var life: Double // 남은 수명 (초)
|
var life: Double // 남은 수명 (초)
|
||||||
var size: CGFloat // 파편 기본 크기 (pt)
|
var size: CGFloat // 파편 기본 크기 (pt)
|
||||||
|
var isRain: Bool // 낙하 파편 여부
|
||||||
|
var gravityScale: CGFloat // 중력 계수(파티클별 낙하 속도 분산)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class LiveRoomViewModel: NSObject, ObservableObject {
|
final class LiveRoomViewModel: NSObject, ObservableObject {
|
||||||
@@ -232,6 +235,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
@Published var remoteWavePhase: CGFloat = 0
|
@Published var remoteWavePhase: CGFloat = 0
|
||||||
@Published var bigHeartParticles: [BigHeartParticle] = []
|
@Published var bigHeartParticles: [BigHeartParticle] = []
|
||||||
|
|
||||||
|
// 최근 폭발 파편의 개수/크기 기록(낙하 효과에 사용)
|
||||||
|
private var lastExplosionCount: Int = 0
|
||||||
|
private var lastExplosionSizes: [CGFloat] = []
|
||||||
|
// 폭발 파편이 모두 사라진 직후 비(낙하)를 스폰해야 하는지 여부
|
||||||
|
private var shouldSpawnRainAfterExplosionEnds: Bool = false
|
||||||
|
|
||||||
var signatureImageUrls = [String]()
|
var signatureImageUrls = [String]()
|
||||||
var signatureList = [LiveRoomDonationResponse]()
|
var signatureList = [LiveRoomDonationResponse]()
|
||||||
var isShowSignatureImage = false
|
var isShowSignatureImage = false
|
||||||
@@ -2149,9 +2158,9 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
var vy = CGFloat(sin(angle)) * speed
|
var vy = CGFloat(sin(angle)) * speed
|
||||||
// 조금 더 위로 포물선을 그리도록 초기 위쪽 임펄스 추가
|
// 조금 더 위로 포물선을 그리도록 초기 위쪽 임펄스 추가
|
||||||
vy -= CGFloat.random(in: 120...220)
|
vy -= CGFloat.random(in: 120...220)
|
||||||
// 크기: 30~80pt
|
// 크기: 20~65pt
|
||||||
let size = CGFloat.random(in: 20...65)
|
let size = CGFloat.random(in: 20...65)
|
||||||
let scale: CGFloat = 1.0 // 요구사항: 최종 크기 20~65 유지 (시간 경과에 따라 약간 축소)
|
let scale: CGFloat = 1.0 // 최종 크기 유지(시간 경과에 따라 약간 축소)
|
||||||
let life: Double = Double.random(in: 1.0...1.5)
|
let life: Double = Double.random(in: 1.0...1.5)
|
||||||
let particle = BigHeartParticle(
|
let particle = BigHeartParticle(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
@@ -2163,45 +2172,119 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
scale: scale,
|
scale: scale,
|
||||||
rotation: Double.random(in: 0...360),
|
rotation: Double.random(in: 0...360),
|
||||||
life: life,
|
life: life,
|
||||||
size: size
|
size: size,
|
||||||
|
isRain: false,
|
||||||
|
gravityScale: 1.0
|
||||||
)
|
)
|
||||||
particles.append(particle)
|
particles.append(particle)
|
||||||
}
|
}
|
||||||
|
// 기록: 낙하 효과에 동일 개수/크기를 사용하기 위해 저장
|
||||||
|
self.lastExplosionCount = count
|
||||||
|
self.lastExplosionSizes = particles.map { $0.size }
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.bigHeartParticles = particles
|
self.bigHeartParticles = particles
|
||||||
}
|
}
|
||||||
|
// 폭발 파편이 모두 사라진 직후 낙하 하트를 생성하도록 플래그 설정
|
||||||
|
self.shouldSpawnRainAfterExplosionEnds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BIG HEART - Rain Particles (after explosion)
|
||||||
|
private func spawnHeartRainFromLastExplosion() -> [BigHeartParticle] {
|
||||||
|
let count = max(0, self.lastExplosionCount)
|
||||||
|
guard count > 0 else { return [] }
|
||||||
|
let bounds = UIScreen.main.bounds
|
||||||
|
let startYBase = -bounds.height * 0.5 - 40 // 화면 위쪽 바깥에서 시작(기본)
|
||||||
|
var rains: [BigHeartParticle] = []
|
||||||
|
rains.reserveCapacity(count)
|
||||||
|
for i in 0..<count {
|
||||||
|
// 화면 너비에 균등 분포 + 약간의 지터
|
||||||
|
let ratio = (CGFloat(i) + 0.5) / CGFloat(count)
|
||||||
|
var x = (ratio - 0.5) * bounds.width
|
||||||
|
x += CGFloat.random(in: -20...20)
|
||||||
|
// 크기는 폭발과 동일 인덱스를 사용(없으면 기본 범위)
|
||||||
|
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 vy = CGFloat.random(in: 10...500) // 상한 220→500로 확대하여 낙하 속도 분산 강화
|
||||||
|
let startY = startYBase + CGFloat.random(in: -80...20) // 시작 높이 지터로 낙하 거리 다양화
|
||||||
|
let sizeNorm = max(0, min(1, (size - 30) / 120))
|
||||||
|
var gScale = 0.8 + 0.8 * sizeNorm // 0.8..1.6 (size가 클수록 빠르게)
|
||||||
|
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 = max(0.5, min(1.5, gScale))
|
||||||
|
let p = BigHeartParticle(
|
||||||
|
id: UUID(),
|
||||||
|
x: x,
|
||||||
|
y: startY,
|
||||||
|
vx: vx,
|
||||||
|
vy: vy,
|
||||||
|
opacity: 1.0,
|
||||||
|
scale: 1.0,
|
||||||
|
rotation: Double.random(in: 0...360),
|
||||||
|
life: 4.0, // 최대 4초 유지
|
||||||
|
size: size,
|
||||||
|
isRain: true,
|
||||||
|
gravityScale: gScale
|
||||||
|
)
|
||||||
|
rains.append(p)
|
||||||
|
}
|
||||||
|
return rains
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startParticlesTimer() {
|
private func startParticlesTimer() {
|
||||||
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.bigHeartParticleTimer?.cancel()
|
// 이미 타이머가 동작 중이면 재시작하지 않음
|
||||||
self.bigHeartParticleTimer = nil
|
guard self.bigHeartParticleTimer == nil else { return }
|
||||||
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
|
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
|
||||||
let dt: Double = 1.0 / 60.0
|
let dt: Double = 1.0 / 60.0
|
||||||
let gravity: CGFloat = 600 // px/s^2
|
let gravity: CGFloat = 600 // px/s^2
|
||||||
timer.schedule(deadline: .now(), repeating: dt)
|
timer.schedule(deadline: .now(), repeating: dt)
|
||||||
timer.setEventHandler { [weak self] in
|
timer.setEventHandler { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
// 폭발 파편 존재 여부(이전 프레임)
|
||||||
|
let hadExplosionBefore = self.bigHeartParticles.contains(where: { !$0.isRain })
|
||||||
var next: [BigHeartParticle] = []
|
var next: [BigHeartParticle] = []
|
||||||
next.reserveCapacity(self.bigHeartParticles.count)
|
next.reserveCapacity(self.bigHeartParticles.count)
|
||||||
|
// 화면 기준(오버레이 중심 좌표계)
|
||||||
|
let bounds = UIScreen.main.bounds
|
||||||
|
let floorY = bounds.height * 0.5 - 60 // 바닥 임계치
|
||||||
for var p in self.bigHeartParticles {
|
for var p in self.bigHeartParticles {
|
||||||
// 물리 업데이트
|
// 물리 업데이트
|
||||||
p.vy += gravity * CGFloat(dt)
|
p.vy += gravity * p.gravityScale * CGFloat(dt)
|
||||||
p.x += p.vx * CGFloat(dt)
|
p.x += p.vx * CGFloat(dt)
|
||||||
p.y += p.vy * CGFloat(dt)
|
p.y += p.vy * CGFloat(dt)
|
||||||
// 회전 (속도에 비례한 간단한 모델)
|
// 회전 (속도에 비례한 간단한 모델)
|
||||||
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 = 1.2 // opacity 계산 기준
|
let baseLife: Double = p.isRain ? 4.0 : 1.2
|
||||||
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
|
||||||
|
// 낙하 파편은 바닥에 닿으면 제거
|
||||||
|
if p.isRain && p.y >= floorY {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if p.life > 0 && p.opacity > 0.01 {
|
if p.life > 0 && p.opacity > 0.01 {
|
||||||
next.append(p)
|
next.append(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 폭발 파편 존재 여부(현재 프레임)
|
||||||
|
let hasExplosionAfter = next.contains(where: { !$0.isRain })
|
||||||
|
// 요구사항: 폭발 파편이 모두 사라진 직후 비를 시작
|
||||||
|
if hadExplosionBefore && !hasExplosionAfter && self.shouldSpawnRainAfterExplosionEnds {
|
||||||
|
let rains = self.spawnHeartRainFromLastExplosion()
|
||||||
|
if !rains.isEmpty {
|
||||||
|
next.append(contentsOf: rains)
|
||||||
|
}
|
||||||
|
self.shouldSpawnRainAfterExplosionEnds = false
|
||||||
|
}
|
||||||
self.bigHeartParticles = next
|
self.bigHeartParticles = next
|
||||||
if next.isEmpty {
|
if next.isEmpty {
|
||||||
self.bigHeartParticleTimer?.cancel()
|
self.bigHeartParticleTimer?.cancel()
|
||||||
|
|||||||
Reference in New Issue
Block a user