diff --git a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift index 4819a63..8f59d52 100644 --- a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift +++ b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift @@ -217,7 +217,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject { // HEART_DONATION/BIG_HEART_DONATION 메시지 표시 시간 제어 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 menuId = 0 @@ -250,6 +250,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject { private var shouldSpawnRainAfterExplosionEnds: Bool = false // 진행 중인 연쇄 폭발(버스트) 남은 횟수(중앙 1회 + 랜덤 6회 = 7) 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 signatureList = [LiveRoomDonationResponse]() @@ -2110,11 +2116,18 @@ final class LiveRoomViewModel: NSObject, ObservableObject { // 로컬 발신 직후 1회는 원격 물 채움 연출을 생략하고 바로 연쇄 폭발만 실행 if suppressNextRemoteWaterFill { suppressNextRemoteWaterFill = false - scheduleExplosionBursts() startParticlesTimer() + startExplosionSequence() 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 guard let self = self else { return } self.remoteWaterTimer?.cancel() @@ -2134,8 +2147,8 @@ final class LiveRoomViewModel: NSObject, ObservableObject { self.remoteWaterProgress = 0 self.remoteWavePhase = 0 self.remoteHeartScale = 1.0 - self.scheduleExplosionBursts() self.startParticlesTimer() + self.startExplosionSequence() } } } @@ -2219,36 +2232,64 @@ final class LiveRoomViewModel: NSObject, ObservableObject { } } - /// 0.7초 간격으로 총 7회(중앙 1회 + 랜덤 6회) 연쇄 폭발을 스케줄링 - private func scheduleExplosionBursts() { - let bursts = 7 - let interval: Double = 0.7 - self.pendingExplosionBursts = bursts - self.shouldSpawnRainAfterExplosionEnds = true - let bounds = UIScreen.main.bounds - let margin: CGFloat = 80 - for i in 0..= 7 { + // 시퀀스 종료 + timer.cancel() + self.explosionSequenceTimer = nil + + // 마지막 폭발 후 0.5초 지연 뒤 비 시작 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } + 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.pendingExplosionBursts = max(0, self.pendingExplosionBursts - 1) - // 타이머가 없으면 시작(보호) if self.bigHeartParticleTimer == nil { 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() { DispatchQueue.main.async { [weak self] in guard let self = self 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 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.setEventHandler { [weak self] in 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] = [] - next.reserveCapacity(self.bigHeartParticles.count) - // 화면 기준(오버레이 중심 좌표계) - let bounds = UIScreen.main.bounds - let floorY = bounds.height * 0.5 - 60 // 바닥 임계치 - for var p in self.bigHeartParticles { - // 물리 업데이트 + next.reserveCapacity(snapshot.count) + for var p in snapshot { p.vy += gravity * p.gravityScale * CGFloat(dt) - p.x += p.vx * CGFloat(dt) - p.y += p.vy * 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 = p.isRain ? 4.0 : 0.7 p.opacity = max(0, min(1, p.life / baseLife)) - // 잔상감 주기 위해 살짝 스케일 감소 p.scale *= 0.995 - // 낙하 파편은 바닥에 닿으면 제거 - if p.isRain && p.y >= floorY { - continue - } - if p.life > 0 && p.opacity > 0.01 { - next.append(p) - } + if p.isRain && p.y >= floorY { continue } + if p.life > 0 && p.opacity > 0.01 { next.append(p) } } - // 폭발 파편 존재 여부(현재 프레임) - let hasExplosionAfter = next.contains(where: { !$0.isRain }) - // 요구사항: 모든 연쇄 폭발이 끝나고 폭발 파편이 모두 사라진 직후 비를 시작 - if hadExplosionBefore && !hasExplosionAfter && self.shouldSpawnRainAfterExplosionEnds && self.pendingExplosionBursts == 0 { - let rains = self.spawnHeartRainFromLastExplosion() - if !rains.isEmpty { - next.append(contentsOf: rains) - } - self.shouldSpawnRainAfterExplosionEnds = false + + // 파티클 상한(과부하 방지) + let maxParticles = 320 + if next.count > maxParticles { + next.removeFirst(next.count - maxParticles) } - self.bigHeartParticles = next - // 타이머 종료: 파티클이 비어 있고, 추가 예정인 폭발도 없을 때만 종료 - if next.isEmpty && self.pendingExplosionBursts == 0 { - self.bigHeartParticleTimer?.cancel() - self.bigHeartParticleTimer = nil + + // 결과 반영만 메인에서(스냅샷 이후 추가된 파티클 병합) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + // 스냅샷 이후 메인에서 추가된 파티클을 보존하기 위해 병합 + 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() diff --git a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift index 7721e1b..2672840 100644 --- a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift +++ b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift @@ -767,7 +767,7 @@ struct LiveRoomViewV2: View { .opacity(showWaterHeart ? 1 : 0) .animation(.easeInOut(duration: 0.2), value: showWaterHeart) - // 원격 수신 시(또는 공통 트리거) 물 채우기 하트 - 0→1 스케일 업(0.5s) + // 원격 수신 시(또는 공통 트리거) 물 채우기 하트 - 0→1 스케일 업(1.0~1.5s 랜덤) WaterHeartView(progress: viewModel.remoteWaterProgress, show: viewModel.isShowRemoteBigHeart, phase: viewModel.remoteWavePhase) @@ -790,6 +790,9 @@ struct LiveRoomViewV2: View { .allowsHitTesting(false) } } + // drawingGroup은 레이어의 경계(Rect) 밖을 클립하므로, 전체 화면 크기로 확장해 클리핑 방지 + .frame(maxWidth: .infinity, maxHeight: .infinity) + .drawingGroup(opaque: false, colorMode: .linear) } // 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동 .offset(y: keyboardHandler.keyboardHeight > 0 ? -(keyboardHandler.keyboardHeight / 2 + 60) : 0)