feat(live-room-like-heart): 폭발 후 하트 비/우박 애니메이션 반영

This commit is contained in:
2025-11-05 00:57:30 +09:00
parent a24b1a3b4e
commit c4fc075844

View File

@@ -14,6 +14,7 @@ import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Path import android.graphics.Path
import android.graphics.Matrix
import android.graphics.Rect import android.graphics.Rect
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
@@ -2363,6 +2364,14 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
) )
root.addView(overlay, lp) root.addView(overlay, lp)
overlay.start() overlay.start()
// 폭발 직후 0.01~0.1초 랜덤 지연 후 하트 비/우박 애니메이션 시작
val rainDelay = (10L..100L).random()
handler.postDelayed({
val rainView = HeartRainView(this)
root.addView(rainView, lp)
rainView.start()
}, rainDelay)
} }
private class HeartExplosionView( private class HeartExplosionView(
@@ -2468,6 +2477,152 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
} }
} }
private class HeartRainView(
context: Context
) : View(context) {
private data class Drop(
val x0: Float,
val y0: Float,
val v0: Float,
val sizePx: Float,
val halfSize: Float,
val rotation0: Float,
val rotationSpeed: Float,
val fadeStartY: Float,
val path: Path,
var landed: Boolean
)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = "#ff959a".toColorInt()
}
private val drops = mutableListOf<Drop>()
private val baseHeartPath: Path = Path().apply {
// 하트 실루엣(정규화 좌표: x,y ∈ [-1,1])
// 상단 중앙 노치가 있고 좌우 로브가 둥글며 하단 포인트가 뾰족한 형태
// y 범위 [-1, 1] 유지(halfSize 기반 착지/충돌 판정과 정합)
reset()
moveTo(0f, -0.2f) // top notch
// right lobe → bottom tip
cubicTo(
0.85f, -1.0f, // control1: right lobe top
1.35f, 0.2f, // control2: right shoulder
0f, 1f // bottom tip
)
// left lobe → back to notch
cubicTo(
-1.35f, 0.2f, // control1: left shoulder
-0.85f, -1.0f, // control2: left lobe top
0f, -0.2f // notch
)
close()
}
private var started = false
private var startTimeMs = 0L
private val gravity = 1200f // px/s^2
private val maxDurationMs = 4000L
fun start() {
if (width == 0 || height == 0) {
// 레이아웃 완료 후 시작
post { start() }
return
}
if (started) return
started = true
generateDrops()
startTimeMs = System.currentTimeMillis()
invalidate()
}
private fun generateDrops() {
drops.clear()
val count = (30..50).random()
val w = width.toFloat()
val h = height.toFloat()
repeat(count) {
val sizePx = (10..50).random().dpToPx()
val half = sizePx / 2f
val x = (Math.random().toFloat() * w)
val v0 = 100f + (1500f * Math.random().toFloat()) // 200..3000 px/s (초기 속도)
val rotateEnabled = Math.random() < 0.3
val rotationSpeed = if (rotateEnabled) 300f * Math.random().toFloat() else 0f // deg/s
val rotation0 = (-180f + 360f * Math.random().toFloat())
val fadeStartRatio = 0.80f + 0.15f * Math.random().toFloat() // 0.80..0.95
val fadeStartY = h * fadeStartRatio
// 드롭 전용 Path (정규화 경로를 half 크기로 스케일)
val m = Matrix().apply { setScale(half, half) }
val scaledPath = Path().also { baseHeartPath.transform(m, it) }
drops += Drop(
x0 = x,
y0 = -100f,
v0 = v0,
sizePx = sizePx,
halfSize = half,
rotation0 = rotation0,
rotationSpeed = rotationSpeed,
fadeStartY = fadeStartY,
path = scaledPath,
landed = false
)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (!started) return
val now = System.currentTimeMillis()
val t = ((now - startTimeMs).coerceAtLeast(0L) / 1000f)
val h = height.toFloat()
var allLanded = true
drops.forEach { d ->
// 위치 계산 (중력 가속도 포함)
val yRaw = d.y0 + d.v0 * t + 0.5f * gravity * t * t
val bottomY = h - d.halfSize
val y = if (yRaw >= bottomY) bottomY else yRaw
if (y < bottomY) allLanded = false else d.landed = true
val x = d.x0
// 하단 도달 전 페이드 아웃
val fadeAlpha = if (y >= d.fadeStartY) {
val denom = (h - d.fadeStartY).coerceAtLeast(1f)
(1f - (y - d.fadeStartY) / denom).coerceIn(0f, 1f)
} else 1f
val alpha = fadeAlpha.coerceIn(0f, 1f)
paint.alpha = (alpha * 255).toInt().coerceIn(0, 255)
val rot = d.rotation0 + d.rotationSpeed * t
canvas.withTranslation(x, y) {
rotate(rot)
drawPath(d.path, paint)
}
}
val finished = allLanded || (now - startTimeMs) >= maxDurationMs
if (finished) {
(parent as? FrameLayout)?.removeView(this)
return
}
postInvalidateOnAnimation()
}
}
// endregion // endregion
// region signature // region signature