feat(live-room): 왕하트 애니메이션 수정

- 기존 가운데에서 한 번 폭발 후 비 내리는 애니메이션에서 가운데 + 랜덤 위치로 총 7번 폭발 후 비 내리는 애니메이션으로 수정
This commit is contained in:
2025-11-17 16:57:27 +09:00
parent 868b2d309a
commit bbb7858508

View File

@@ -2371,22 +2371,48 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
target.visibility = View.GONE target.visibility = View.GONE
longPressCenterHeart = null longPressCenterHeart = null
// 하트 파티클 폭발 오버레이 추가 // 중앙 1회 + 랜덤 위치 6회 순차 폭발 설정 (총 4.9초)
val overlay = HeartExplosionView(this, cx, cy) val perDuration = 700L // ms (7회 * 0.7s = 3.5s)
val totalExplosions = 7
val totalDuration = perDuration * totalExplosions
val lp = FrameLayout.LayoutParams( val lp = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT FrameLayout.LayoutParams.MATCH_PARENT
) )
root.addView(overlay, lp)
overlay.start()
// 폭발 직후 0.01~0.1초 랜덤 지연 후 하트 비/우박 애니메이션 시작 // 랜덤 좌표 생성 준비 (root 내부, 안전 마진 포함)
val rainDelay = (10L..100L).random() 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({ handler.postDelayed({
val rainView = HeartRainView(this) val rainView = HeartRainView(this)
root.addView(rainView, lp) root.addView(rainView, lp)
rainView.start() rainView.start()
}, rainDelay) }, totalDuration)
} }
// BIG_HEART 수신 시 중앙에 잠깐 하트를 그렸다가, 같은 뷰에서 바로 폭발까지 표현하고 사라지는 뷰 // BIG_HEART 수신 시 중앙에 잠깐 하트를 그렸다가, 같은 뷰에서 바로 폭발까지 표현하고 사라지는 뷰
@@ -2506,57 +2532,57 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
private fun startExplosion(cx: Float, cy: Float) { private fun startExplosion(cx: Float, cy: Float) {
explosionCenterX = cx // 수신자도 발신자와 동일한 7회 폭발 + 총 4.9초 후 비/우박 애니메이션을 적용
explosionCenterY = cy val parentFL = parent as? FrameLayout ?: run {
phase = Phase.EXPLODE (parent as? FrameLayout)?.removeView(this@HeartFloatView)
return
// 파티클 초기화
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)
} }
// 폭발과 함께 하트비/우박 애니메이션을 부모에 요청 // 이 뷰는 표시를 마쳤으므로 제거하고, 폭발/비 애니메이션은 오버레이 뷰들로 진행
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( val lp = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
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로 처리 // 랜덤 좌표 생성 준비 (parent 기준, 안전 마진 포함)
alpha = 1f val margin = 32f.dpToPx()
explosionAnimator = ValueAnimator.ofFloat(0f, 1f).apply { val rw = if (parentFL.width > 0) parentFL.width else parentFL.measuredWidth
duration = explosionDurationMs val rh = if (parentFL.height > 0) parentFL.height else parentFL.measuredHeight
interpolator = AccelerateDecelerateInterpolator() val dm = resources.displayMetrics
addUpdateListener { invalidate() } val fallbackW = if (rw > 0) rw else dm.widthPixels
addListener(object : AnimatorListenerAdapter() { val fallbackH = if (rh > 0) rh else dm.heightPixels
override fun onAnimationEnd(animation: Animator) { val minX = margin
// 애니메이션 종료 시 자기 자신 제거 val maxX = (fallbackW.toFloat() - margin).coerceAtLeast(minX + 1f)
(parent as? FrameLayout)?.removeView(this@HeartFloatView) val minY = margin
} val maxY = (fallbackH.toFloat() - margin).coerceAtLeast(minY + 1f)
})
start() 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) { private fun drawExplosion(canvas: Canvas) {
@@ -2590,7 +2616,8 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private class HeartExplosionView( private class HeartExplosionView(
context: Context, context: Context,
private val centerX: Float, private val centerX: Float,
private val centerY: Float private val centerY: Float,
private val durationMs: Long = 900L
) : View(context) { ) : View(context) {
private data class Particle( private data class Particle(
@@ -2627,7 +2654,6 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
close() close()
} }
private val durationMs = 900L
private val gravity = 1400f // px/s^2 private val gravity = 1400f // px/s^2
private val alphaStart = 1f private val alphaStart = 1f
private val alphaEnd = 0f private val alphaEnd = 0f