From c4fc075844d6118baa6128c7efa4e0e36ec36b27 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 5 Nov 2025 00:57:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(live-room-like-heart):=20=ED=8F=AD?= =?UTF-8?q?=EB=B0=9C=20=ED=9B=84=20=ED=95=98=ED=8A=B8=20=EB=B9=84/?= =?UTF-8?q?=EC=9A=B0=EB=B0=95=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/live/room/LiveRoomActivity.kt | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) 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 f61dd625..d3a1950e 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 @@ -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(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(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() + 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