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.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
@@ -2363,6 +2364,14 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
|
||||
)
|
||||
root.addView(overlay, lp)
|
||||
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(
|
||||
@@ -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
|
||||
|
||||
// region signature
|
||||
|
||||
Reference in New Issue
Block a user