diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt index b2d5a1dc..abba0b12 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt @@ -2371,22 +2371,48 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB target.visibility = View.GONE longPressCenterHeart = null - // 하트 파티클 폭발 오버레이 추가 - val overlay = HeartExplosionView(this, cx, cy) + // 중앙 1회 + 랜덤 위치 6회 순차 폭발 설정 (총 4.9초) + val perDuration = 700L // ms (7회 * 0.7s = 3.5s) + val totalExplosions = 7 + val totalDuration = perDuration * totalExplosions val lp = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) - root.addView(overlay, lp) - overlay.start() - // 폭발 직후 0.01~0.1초 랜덤 지연 후 하트 비/우박 애니메이션 시작 - val rainDelay = (10L..100L).random() + // 랜덤 좌표 생성 준비 (root 내부, 안전 마진 포함) + val margin = 32f.dpToPx() + val rw = if (root.width > 0) root.width else root.measuredWidth + val rh = if (root.height > 0) root.height else root.measuredHeight + val dm = resources.displayMetrics + val fallbackW = if (rw > 0) rw else dm.widthPixels + val fallbackH = if (rh > 0) rh else dm.heightPixels + val minX = margin + val maxX = (fallbackW.toFloat() - margin).coerceAtLeast(minX + 1f) + val minY = margin + val maxY = (fallbackH.toFloat() - margin).coerceAtLeast(minY + 1f) + + fun randomX(): Float = minX + Random.nextFloat() * (maxX - minX) + fun randomY(): Float = minY + Random.nextFloat() * (maxY - minY) + + // 0: 중앙, 1..4: 랜덤 위치 + repeat(totalExplosions) { index -> + val delay = (index * perDuration - 100).coerceAtLeast(100L) + val ex = if (index == 0) cx else randomX() + val ey = if (index == 0) cy else randomY() + handler.postDelayed({ + val overlay = HeartExplosionView(this, ex, ey, perDuration) + root.addView(overlay, lp) + overlay.start() + }, delay) + } + + // 모든 폭발이 끝난 뒤 하트 비/우박 애니메이션 시작 handler.postDelayed({ val rainView = HeartRainView(this) root.addView(rainView, lp) rainView.start() - }, rainDelay) + }, totalDuration) } // BIG_HEART 수신 시 중앙에 잠깐 하트를 그렸다가, 같은 뷰에서 바로 폭발까지 표현하고 사라지는 뷰 @@ -2506,57 +2532,57 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB } private fun startExplosion(cx: Float, cy: Float) { - explosionCenterX = cx - explosionCenterY = cy - phase = Phase.EXPLODE - - // 파티클 초기화 - particles.clear() - val density = resources.displayMetrics.density - val count = 90 - val minScalePx = 4f * density - val maxScalePx = 10f * density - val minSpeed = 350f * density - val maxSpeed = 1100f * density - repeat(count) { - val angle = (Math.random() * Math.PI * 2).toFloat() - val speed = minSpeed + (maxSpeed - minSpeed) * Math.random().toFloat() - val scale = minScalePx + (maxScalePx - minScalePx) * Math.random().toFloat() - val rotation = (Math.random().toFloat() * 360f) - 180f - val rotationSpeed = ((Math.random().toFloat() * 360f) - 180f) - particles += Particle(cx, cy, angle, speed, scale, rotation, rotationSpeed) + // 수신자도 발신자와 동일한 7회 폭발 + 총 4.9초 후 비/우박 애니메이션을 적용 + val parentFL = parent as? FrameLayout ?: run { + (parent as? FrameLayout)?.removeView(this@HeartFloatView) + return } - // 폭발과 함께 하트비/우박 애니메이션을 부모에 요청 - val parentFL = parent as? FrameLayout + // 이 뷰는 표시를 마쳤으므로 제거하고, 폭발/비 애니메이션은 오버레이 뷰들로 진행 + parentFL.removeView(this@HeartFloatView) + + val perDuration = 700L // ms (7회 * 0.7s = 4.9s) + val totalExplosions = 7 + val totalDuration = perDuration * totalExplosions + val lp = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) - val rainDelay = (10L..100L).random() - postDelayed({ - parentFL?.let { p -> - val rainView = HeartRainView(context) - p.addView(rainView, lp) - rainView.start() - } - }, rainDelay) - // 알파 페이드 아웃은 View 자체 alpha로 처리 - alpha = 1f - explosionAnimator = ValueAnimator.ofFloat(0f, 1f).apply { - duration = explosionDurationMs - interpolator = AccelerateDecelerateInterpolator() - addUpdateListener { invalidate() } - addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - // 애니메이션 종료 시 자기 자신 제거 - (parent as? FrameLayout)?.removeView(this@HeartFloatView) - } - }) - start() + // 랜덤 좌표 생성 준비 (parent 기준, 안전 마진 포함) + val margin = 32f.dpToPx() + val rw = if (parentFL.width > 0) parentFL.width else parentFL.measuredWidth + val rh = if (parentFL.height > 0) parentFL.height else parentFL.measuredHeight + val dm = resources.displayMetrics + val fallbackW = if (rw > 0) rw else dm.widthPixels + val fallbackH = if (rh > 0) rh else dm.heightPixels + val minX = margin + val maxX = (fallbackW.toFloat() - margin).coerceAtLeast(minX + 1f) + val minY = margin + val maxY = (fallbackH.toFloat() - margin).coerceAtLeast(minY + 1f) + + fun randomX(): Float = minX + Random.nextFloat() * (maxX - minX) + fun randomY(): Float = minY + Random.nextFloat() * (maxY - minY) + + // 0: 중앙, 1..6: 랜덤 위치 + repeat(totalExplosions) { index -> + val delay = (index * perDuration - 100).coerceAtLeast(100L) + val ex = if (index == 0) cx else randomX() + val ey = if (index == 0) cy else randomY() + parentFL.postDelayed({ + val overlay = HeartExplosionView(context, ex, ey, perDuration) + parentFL.addView(overlay, lp) + overlay.start() + }, delay) } - postInvalidateOnAnimation() + + // 모든 폭발이 끝난 뒤 하트 비/우박 애니메이션 시작 + parentFL.postDelayed({ + val rainView = HeartRainView(context) + parentFL.addView(rainView, lp) + rainView.start() + }, totalDuration) } private fun drawExplosion(canvas: Canvas) { @@ -2590,7 +2616,8 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB private class HeartExplosionView( context: Context, private val centerX: Float, - private val centerY: Float + private val centerY: Float, + private val durationMs: Long = 900L ) : View(context) { private data class Particle( @@ -2627,7 +2654,6 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB close() } - private val durationMs = 900L private val gravity = 1400f // px/s^2 private val alphaStart = 1f private val alphaEnd = 0f