refactor(live-room): BIG_HEART 메시지 수신 애니메이션이 여러번 실행되면 버벅거림과 발열이 생기던 문제 수정
- DispatchQueue로 Concurrent 처리
This commit is contained in:
@@ -217,7 +217,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
|
|
||||||
// HEART_DONATION/BIG_HEART_DONATION 메시지 표시 시간 제어
|
// HEART_DONATION/BIG_HEART_DONATION 메시지 표시 시간 제어
|
||||||
private let heartDonationDisplayDuration: TimeInterval = 1.5
|
private let heartDonationDisplayDuration: TimeInterval = 1.5
|
||||||
private let bigHeartDonationDisplayDuration: TimeInterval = 3.0
|
private let bigHeartDonationDisplayDuration: TimeInterval = 5.0
|
||||||
private var currentHeartMessageDuration: TimeInterval = 1.5
|
private var currentHeartMessageDuration: TimeInterval = 1.5
|
||||||
|
|
||||||
private var menuId = 0
|
private var menuId = 0
|
||||||
@@ -250,6 +250,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
private var shouldSpawnRainAfterExplosionEnds: Bool = false
|
private var shouldSpawnRainAfterExplosionEnds: Bool = false
|
||||||
// 진행 중인 연쇄 폭발(버스트) 남은 횟수(중앙 1회 + 랜덤 6회 = 7)
|
// 진행 중인 연쇄 폭발(버스트) 남은 횟수(중앙 1회 + 랜덤 6회 = 7)
|
||||||
private var pendingExplosionBursts: Int = 0
|
private var pendingExplosionBursts: Int = 0
|
||||||
|
// 폭발 시퀀스 제어(Dispatch 타이머 기반)
|
||||||
|
private var explosionSequenceTimer: DispatchSourceTimer?
|
||||||
|
private var lastSequenceStartedAt: Date = .distantPast
|
||||||
|
private let sequenceMinInterval: TimeInterval = 1.0
|
||||||
|
// 마지막 폭발 후 비 시작 허용 시각(지연 0.5s 반영)
|
||||||
|
private var rainEligibleAt: Date? = nil
|
||||||
|
|
||||||
var signatureImageUrls = [String]()
|
var signatureImageUrls = [String]()
|
||||||
var signatureList = [LiveRoomDonationResponse]()
|
var signatureList = [LiveRoomDonationResponse]()
|
||||||
@@ -2110,11 +2116,18 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
// 로컬 발신 직후 1회는 원격 물 채움 연출을 생략하고 바로 연쇄 폭발만 실행
|
// 로컬 발신 직후 1회는 원격 물 채움 연출을 생략하고 바로 연쇄 폭발만 실행
|
||||||
if suppressNextRemoteWaterFill {
|
if suppressNextRemoteWaterFill {
|
||||||
suppressNextRemoteWaterFill = false
|
suppressNextRemoteWaterFill = false
|
||||||
scheduleExplosionBursts()
|
|
||||||
startParticlesTimer()
|
startParticlesTimer()
|
||||||
|
startExplosionSequence()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 요구사항: 수신자 하트는 0→현재 크기까지 0.5초 동안 스케일 업 후 연쇄 폭발
|
// 스로틀: 과도한 중복 실행 방지
|
||||||
|
let now = Date()
|
||||||
|
if now.timeIntervalSince(lastSequenceStartedAt) < sequenceMinInterval {
|
||||||
|
DEBUG_LOG("BIG_HEART: throttled to protect performance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSequenceStartedAt = now
|
||||||
|
// 요구사항: 수신자 하트는 0→현재 크기까지 1.0~1.5초 랜덤 스케일 업 후 연쇄 폭발
|
||||||
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()
|
||||||
@@ -2134,8 +2147,8 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
self.remoteWaterProgress = 0
|
self.remoteWaterProgress = 0
|
||||||
self.remoteWavePhase = 0
|
self.remoteWavePhase = 0
|
||||||
self.remoteHeartScale = 1.0
|
self.remoteHeartScale = 1.0
|
||||||
self.scheduleExplosionBursts()
|
|
||||||
self.startParticlesTimer()
|
self.startParticlesTimer()
|
||||||
|
self.startExplosionSequence()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2219,36 +2232,64 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 0.7초 간격으로 총 7회(중앙 1회 + 랜덤 6회) 연쇄 폭발을 스케줄링
|
/// 0.7초 간격으로 총 7회(중앙 1회 + 랜덤 6회) 연쇄 폭발을 실행(Dispatch 타이머 기반)
|
||||||
private func scheduleExplosionBursts() {
|
private func startExplosionSequence() {
|
||||||
let bursts = 7
|
// 이전 시퀀스 취소
|
||||||
let interval: Double = 0.7
|
explosionSequenceTimer?.cancel()
|
||||||
self.pendingExplosionBursts = bursts
|
explosionSequenceTimer = nil
|
||||||
self.shouldSpawnRainAfterExplosionEnds = true
|
|
||||||
let bounds = UIScreen.main.bounds
|
DispatchQueue.main.async { [weak self] in
|
||||||
let margin: CGFloat = 80
|
guard let self = self else { return }
|
||||||
for i in 0..<bursts {
|
self.pendingExplosionBursts = 7
|
||||||
let delay = Double(i) * interval
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
let bounds = UIScreen.main.bounds
|
||||||
|
let margin: CGFloat = 80
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
|
||||||
|
timer.schedule(deadline: .now(), repeating: .milliseconds(700)) // 0.7s 간격
|
||||||
|
timer.setEventHandler { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let origin: CGPoint
|
|
||||||
if i == 0 {
|
if index >= 7 {
|
||||||
origin = .zero // 중앙
|
// 시퀀스 종료
|
||||||
} else {
|
timer.cancel()
|
||||||
let minX = -bounds.width * 0.5 + margin
|
self.explosionSequenceTimer = nil
|
||||||
let maxX = bounds.width * 0.5 - margin
|
|
||||||
let minY = -bounds.height * 0.5 + margin
|
// 마지막 폭발 후 0.5초 지연 뒤 비 시작
|
||||||
let maxY = bounds.height * 0.5 - margin
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
origin = CGPoint(x: CGFloat.random(in: minX...maxX),
|
guard let self = self else { return }
|
||||||
y: CGFloat.random(in: minY...maxY))
|
let rains = self.spawnHeartRainFromLastExplosion()
|
||||||
|
if !rains.isEmpty {
|
||||||
|
self.bigHeartParticles.append(contentsOf: rains)
|
||||||
|
if self.bigHeartParticleTimer == nil {
|
||||||
|
self.startParticlesTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0회차는 중앙, 이후 6회는 랜덤 위치
|
||||||
|
let origin: CGPoint = {
|
||||||
|
if index == 0 { return .zero }
|
||||||
|
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
|
||||||
|
return CGPoint(x: .random(in: minX...maxX), y: .random(in: minY...maxY))
|
||||||
|
}()
|
||||||
|
|
||||||
self.spawnHeartExplosion(origin: origin)
|
self.spawnHeartExplosion(origin: origin)
|
||||||
self.pendingExplosionBursts = max(0, self.pendingExplosionBursts - 1)
|
|
||||||
// 타이머가 없으면 시작(보호)
|
|
||||||
if self.bigHeartParticleTimer == nil {
|
if self.bigHeartParticleTimer == nil {
|
||||||
self.startParticlesTimer()
|
self.startParticlesTimer()
|
||||||
}
|
}
|
||||||
|
self.pendingExplosionBursts = max(0, self.pendingExplosionBursts - 1)
|
||||||
|
|
||||||
|
index += 1
|
||||||
}
|
}
|
||||||
|
timer.resume()
|
||||||
|
self.explosionSequenceTimer = timer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2295,57 +2336,77 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
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 }
|
||||||
// 이미 타이머가 동작 중이면 재시작하지 않음
|
|
||||||
guard self.bigHeartParticleTimer == nil else { return }
|
guard self.bigHeartParticleTimer == nil else { return }
|
||||||
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
|
|
||||||
|
// 백그라운드 큐에서 물리 루프 실행, 메인에서는 스냅샷/반영만
|
||||||
|
let queue = DispatchQueue(label: "bigHeart.particles.loop", qos: .userInitiated)
|
||||||
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
|
|
||||||
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
|
||||||
|
let bounds = UIScreen.main.bounds
|
||||||
|
let floorY = bounds.height * 0.5 - 60
|
||||||
|
|
||||||
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 snapshot: [BigHeartParticle] = []
|
||||||
|
var pending = 0
|
||||||
|
var hadExplosionBefore = false
|
||||||
|
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
snapshot = self.bigHeartParticles
|
||||||
|
pending = self.pendingExplosionBursts
|
||||||
|
hadExplosionBefore = snapshot.contains { !$0.isRain }
|
||||||
|
sem.signal()
|
||||||
|
}
|
||||||
|
sem.wait()
|
||||||
|
|
||||||
|
// 물리 업데이트(백그라운드)
|
||||||
var next: [BigHeartParticle] = []
|
var next: [BigHeartParticle] = []
|
||||||
next.reserveCapacity(self.bigHeartParticles.count)
|
next.reserveCapacity(snapshot.count)
|
||||||
// 화면 기준(오버레이 중심 좌표계)
|
for var p in snapshot {
|
||||||
let bounds = UIScreen.main.bounds
|
|
||||||
let floorY = bounds.height * 0.5 - 60 // 바닥 임계치
|
|
||||||
for var p in self.bigHeartParticles {
|
|
||||||
// 물리 업데이트
|
|
||||||
p.vy += gravity * p.gravityScale * 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 = p.isRain ? 4.0 : 0.7
|
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
|
||||||
// 낙하 파편은 바닥에 닿으면 제거
|
if p.isRain && p.y >= floorY { continue }
|
||||||
if p.isRain && p.y >= floorY {
|
if p.life > 0 && p.opacity > 0.01 { next.append(p) }
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p.life > 0 && p.opacity > 0.01 {
|
|
||||||
next.append(p)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 폭발 파편 존재 여부(현재 프레임)
|
|
||||||
let hasExplosionAfter = next.contains(where: { !$0.isRain })
|
// 파티클 상한(과부하 방지)
|
||||||
// 요구사항: 모든 연쇄 폭발이 끝나고 폭발 파편이 모두 사라진 직후 비를 시작
|
let maxParticles = 320
|
||||||
if hadExplosionBefore && !hasExplosionAfter && self.shouldSpawnRainAfterExplosionEnds && self.pendingExplosionBursts == 0 {
|
if next.count > maxParticles {
|
||||||
let rains = self.spawnHeartRainFromLastExplosion()
|
next.removeFirst(next.count - maxParticles)
|
||||||
if !rains.isEmpty {
|
|
||||||
next.append(contentsOf: rains)
|
|
||||||
}
|
|
||||||
self.shouldSpawnRainAfterExplosionEnds = false
|
|
||||||
}
|
}
|
||||||
self.bigHeartParticles = next
|
|
||||||
// 타이머 종료: 파티클이 비어 있고, 추가 예정인 폭발도 없을 때만 종료
|
// 결과 반영만 메인에서(스냅샷 이후 추가된 파티클 병합)
|
||||||
if next.isEmpty && self.pendingExplosionBursts == 0 {
|
DispatchQueue.main.async { [weak self] in
|
||||||
self.bigHeartParticleTimer?.cancel()
|
guard let self = self else { return }
|
||||||
self.bigHeartParticleTimer = nil
|
// 스냅샷 이후 메인에서 추가된 파티클을 보존하기 위해 병합
|
||||||
|
let snapshotIDs = Set(snapshot.map { $0.id })
|
||||||
|
var merged = next
|
||||||
|
if !self.bigHeartParticles.isEmpty {
|
||||||
|
let delta = self.bigHeartParticles.filter { !snapshotIDs.contains($0.id) }
|
||||||
|
if !delta.isEmpty { merged.append(contentsOf: delta) }
|
||||||
|
}
|
||||||
|
// 상한 재적용
|
||||||
|
let maxParticles = 320
|
||||||
|
if merged.count > maxParticles {
|
||||||
|
merged.removeFirst(merged.count - maxParticles)
|
||||||
|
}
|
||||||
|
self.bigHeartParticles = merged
|
||||||
|
if merged.isEmpty && self.pendingExplosionBursts == 0 {
|
||||||
|
self.bigHeartParticleTimer?.cancel()
|
||||||
|
self.bigHeartParticleTimer = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
|
|||||||
@@ -767,7 +767,7 @@ 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)
|
||||||
|
|
||||||
// 원격 수신 시(또는 공통 트리거) 물 채우기 하트 - 0→1 스케일 업(0.5s)
|
// 원격 수신 시(또는 공통 트리거) 물 채우기 하트 - 0→1 스케일 업(1.0~1.5s 랜덤)
|
||||||
WaterHeartView(progress: viewModel.remoteWaterProgress,
|
WaterHeartView(progress: viewModel.remoteWaterProgress,
|
||||||
show: viewModel.isShowRemoteBigHeart,
|
show: viewModel.isShowRemoteBigHeart,
|
||||||
phase: viewModel.remoteWavePhase)
|
phase: viewModel.remoteWavePhase)
|
||||||
@@ -790,6 +790,9 @@ struct LiveRoomViewV2: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// drawingGroup은 레이어의 경계(Rect) 밖을 클립하므로, 전체 화면 크기로 확장해 클리핑 방지
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.drawingGroup(opaque: false, colorMode: .linear)
|
||||||
}
|
}
|
||||||
// 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동
|
// 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동
|
||||||
.offset(y: keyboardHandler.keyboardHeight > 0 ? -(keyboardHandler.keyboardHeight / 2 + 60) : 0)
|
.offset(y: keyboardHandler.keyboardHeight > 0 ? -(keyboardHandler.keyboardHeight / 2 + 60) : 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user