feat(live-room-like-heart): 폭발 후 하트 비/우박 애니메이션 반영
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user