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 0308a829..2b6dd7c5 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 @@ -2288,6 +2288,17 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB }, animateDuration) } + private fun ensureBigHeartFloatView(): HeartFloatView { + val root = binding.flRoot + return HeartFloatView(this).also { view -> + val lp = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + root.addView(view, lp) + } + } + private fun addBigHeartAnimation(fromMessage: Boolean = false) { val heart = binding.heartWave @@ -2299,39 +2310,10 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB } catch (_: Throwable) { } - // 메시지로 수신된 경우: WaterWaveView 대신 임시 하트 뷰를 중앙에 표시 후 폭발 실행 + // 메시지로 수신된 경우: Path 드로잉 기반으로 중앙 하트를 잠깐 표시 후 폭발 실행 if (fromMessage) { - val root = binding.flRoot - // 임시 하트(ImageView) 생성 및 중앙 배치 - val tempHeart = ImageView(this).apply { - setImageResource(R.drawable.ic_heart_pink) - alpha = 0f - scaleX = 0.8f - scaleY = 0.8f - layoutParams = FrameLayout.LayoutParams( - 120f.dpToPx().toInt(), - 120f.dpToPx().toInt() - ).apply { - gravity = android.view.Gravity.CENTER - } - } - root.addView(tempHeart) - - // 짧은 페이드/스케일 인 후 폭발 실행 (레이아웃 완료 보장 위해 애니메이션 콜백에서 처리) - tempHeart.animate() - .alpha(1f) - .scaleX(1f) - .scaleY(1f) - .setDuration(150L) - .withEndAction { - // 임시 하트를 기준으로 폭발 위치 계산 및 실행 - explodeAndHideHeart(tempHeart) - // 누적 방지: 일정 시간 후 임시 하트 제거 - handler.postDelayed({ - (tempHeart.parent as? FrameLayout)?.removeView(tempHeart) - }, 1200L) - } - .start() + val floatView = ensureBigHeartFloatView() + floatView.emitCenterOnce(sizeDp = 133.3f, showDurationMs = 150L) return } @@ -2402,6 +2384,203 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB }, rainDelay) } + // BIG_HEART 수신 시 중앙에 잠깐 하트를 그렸다가, 같은 뷰에서 바로 폭발까지 표현하고 사라지는 뷰 + private class HeartFloatView( + context: Context + ) : View(context) { + + private data class Heart( + var x: Float, + var y: Float, + var scale: Float, + var alpha: Float, + var tMs: Float, + val durationInMs: Long + ) + + private data class Particle( + var x0: Float, + var y0: Float, + var angle: Float, + var speed: Float, + var scale: Float, + var rotation: Float, + var rotationSpeed: Float + ) + + private enum class Phase { SHOW, EXPLODE } + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = "#ff959a".toColorInt() + } + + private val baseHeartPath: Path = Path().apply { + // 하트 경로(정규화 좌표계 [-1,1]) + reset() + moveTo(0f, -0.2f) + cubicTo( + 0.85f, -1.0f, + 1.35f, 0.2f, + 0f, 1f + ) + cubicTo( + -1.35f, 0.2f, + -0.85f, -1.0f, + 0f, -0.2f + ) + close() + } + + private val hearts = mutableListOf() + private var phase: Phase = Phase.SHOW + + // 폭발용 상태 + private val particles = mutableListOf() + private var explosionAnimator: ValueAnimator? = null + private val explosionDurationMs = 900L + private val gravity = 1400f // px/s^2 + private var explosionCenterX = 0f + private var explosionCenterY = 0f + + // 화면 중앙에 하트를 잠깐 표시 후 자동 폭발 + fun emitCenterOnce(sizeDp: Float = 133.3f, showDurationMs: Long = 150L) { + if (width == 0 || height == 0) { + post { emitCenterOnce(sizeDp, showDurationMs) } + return + } + val cx = width / 2f + val cy = height / 2f + val h = Heart( + x = cx, + y = cy, + scale = sizeDp.dpToPx() / 2f, + alpha = 0f, + tMs = 0f, + durationInMs = showDurationMs + ) + hearts.clear() + hearts += h + phase = Phase.SHOW + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + when (phase) { + Phase.SHOW -> drawAndMaybeStartExplosion(canvas) + Phase.EXPLODE -> drawExplosion(canvas) + } + } + + private fun drawAndMaybeStartExplosion(canvas: Canvas) { + val iterator = hearts.iterator() + if (!iterator.hasNext()) return + val h = iterator.next() + + // 고정 16ms 스텝 + h.tMs += 16f + val f = (h.tMs / h.durationInMs).coerceIn(0f, 1f) + + val drawAlpha = (f * 255).toInt().coerceIn(0, 255) + paint.alpha = drawAlpha + + canvas.withTranslation(h.x, h.y) { + val s = h.scale + scale(s, s) + drawPath(baseHeartPath, paint) + } + + if (f >= 1f) { + iterator.remove() + startExplosion(h.x, h.y) + } else { + postInvalidateOnAnimation() + } + } + + private fun startExplosion(cx: Float, cy: Float) { + explosionCenterX = cx + explosionCenterY = cy + phase = Phase.EXPLODE + + // 파티클 초기화 + 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 + val lp = FrameLayout.LayoutParams( + 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로 처리 + alpha = 1f + explosionAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = explosionDurationMs + interpolator = AccelerateDecelerateInterpolator() + addUpdateListener { invalidate() } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + // 애니메이션 종료 시 자기 자신 제거 + (parent as? FrameLayout)?.removeView(this@HeartFloatView) + } + }) + start() + } + postInvalidateOnAnimation() + } + + private fun drawExplosion(canvas: Canvas) { + val fraction = (explosionAnimator?.animatedFraction ?: 0f).coerceIn(0f, 1f) + val t = fraction * (explosionDurationMs / 1000f) // seconds + + // 전체 뷰 알파를 1→0으로 + alpha = 1f - fraction + + particles.forEach { p -> + val vx = kotlin.math.cos(p.angle) * p.speed + val vy0 = kotlin.math.sin(p.angle) * p.speed + val x = p.x0 + vx * t + val y = p.y0 + vy0 * t + 0.5f * gravity * t * t + val rot = p.rotation + p.rotationSpeed * t + + canvas.withTranslation(x, y) { + rotate(rot) + val s = p.scale + scale(s, s) + drawPath(baseHeartPath, paint) + } + } + + if (fraction < 1f) { + postInvalidateOnAnimation() + } + } + } + private class HeartExplosionView( context: Context, private val centerX: Float,