유틸리티 및 화면 플립 효과 추가

This commit is contained in:
2026-10-11 14:19:28 +09:00
parent 2d9497a7d7
commit 81e577dce2
23 changed files with 2320 additions and 0 deletions
@@ -0,0 +1,131 @@
package com.wordpuzzle.hangul.ui.game
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.withTransform
import kotlinx.coroutines.isActive
import kotlin.random.Random
data class ConfettiParticle(
var x: Float,
var y: Float,
var vx: Float,
var vy: Float,
var size: Float,
var color: Color,
var rotation: Float,
var vRot: Float,
var isCircle: Boolean,
var alpha: Float = 1.0f
)
private val CONFETTI_COLORS = listOf(
Color(0xFFFFD54F), // 황금 노랑
Color(0xFFE57373), // 상큼 로즈 핑크
Color(0xFF64B5F6), // 스카이 블루
Color(0xFF81C784), // 민트 그린
Color(0xFFBA68C8), // 라벤더
Color(0xFFFF8A65), // 피치 오렌지
Color(0xFF4DB6AC) // 세이지 틸
)
@Composable
fun ConfettiParticleCanvas(
modifier: Modifier = Modifier,
particleCount: Int = 75,
onAnimationEnd: () -> Unit = {}
) {
var particles by remember { mutableStateOf<List<ConfettiParticle>>(emptyList()) }
var animationTime by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
val rand = Random(System.currentTimeMillis())
val list = mutableListOf<ConfettiParticle>()
for (i in 0 until particleCount) {
val startX = rand.nextFloat() * 1000f
val startY = -50f - rand.nextFloat() * 300f
val vx = (rand.nextFloat() - 0.5f) * 12f
val vy = 10f + rand.nextFloat() * 18f
val size = 12f + rand.nextFloat() * 16f
val color = CONFETTI_COLORS[rand.nextInt(CONFETTI_COLORS.size)]
val rotation = rand.nextFloat() * 360f
val vRot = (rand.nextFloat() - 0.5f) * 15f
val isCircle = rand.nextBoolean()
list.add(
ConfettiParticle(
x = startX,
y = startY,
vx = vx,
vy = vy,
size = size,
color = color,
rotation = rotation,
vRot = vRot,
isCircle = isCircle
)
)
}
particles = list
val startTime = withFrameNanos { it }
val durationNanos = 2_800_000_000L // 2.8초
while (isActive) {
val currentNanos = withFrameNanos { it }
val elapsedNanos = currentNanos - startTime
val progress = (elapsedNanos.toFloat() / durationNanos.toFloat()).coerceIn(0f, 1f)
animationTime = progress
for (p in particles) {
p.x += p.vx
p.y += p.vy
p.vy += 0.35f // gravity
p.vx *= 0.98f // air drag
p.rotation += p.vRot
if (progress > 0.7f) {
p.alpha = ((1f - progress) / 0.3f).coerceIn(0f, 1f)
}
}
if (progress >= 1f) {
onAnimationEnd()
break
}
}
}
Canvas(modifier = modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
for (p in particles) {
val px = (p.x / 1000f) * canvasWidth
val py = (p.y / 1200f) * canvasHeight
withTransform({
rotate(degrees = p.rotation, pivot = Offset(px, py))
}) {
if (p.isCircle) {
drawCircle(
color = p.color.copy(alpha = p.color.alpha * p.alpha),
radius = p.size / 2f,
center = Offset(px, py)
)
} else {
drawRect(
color = p.color.copy(alpha = p.color.alpha * p.alpha),
topLeft = Offset(px - p.size / 2f, py - p.size / 2f),
size = Size(p.size, p.size * 0.6f)
)
}
}
}
}
}
@@ -0,0 +1,100 @@
package com.wordpuzzle.hangul.ui.game
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun StageClearStampOverlay(
modifier: Modifier = Modifier,
stageNumber: Int = 1
) {
// 쾅! 하고 힘차게 찍히는 중후한 스탬프 바운스 애니메이션
val scaleAnim = remember { Animatable(2.8f) }
val rotateAnim = remember { Animatable(-18f) }
val alphaAnim = remember { Animatable(0f) }
LaunchedEffect(Unit) {
alphaAnim.animateTo(1f)
scaleAnim.animateTo(
targetValue = 1.0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
)
rotateAnim.animateTo(
targetValue = -9f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioHighBouncy,
stiffness = Spring.StiffnessMedium
)
)
}
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.size(240.dp)
.scale(scaleAnim.value)
.rotate(rotateAnim.value)
.border(6.dp, Color(0xFFC62828), CircleShape),
shape = CircleShape,
color = Color(0xFFFFF5F5), // 선명한 한지 인주 배경
shadowElevation = 16.dp
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
.border(3.dp, Color(0xFFB71C1C), CircleShape),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "祝 클리어",
fontSize = 20.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFFB71C1C)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "참 잘했어요!",
fontSize = 32.sp,
fontWeight = FontWeight.Black,
color = Color(0xFFC62828)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "STAGE $stageNumber",
fontSize = 15.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFFD32F2F)
)
}
}
}
}
}
@@ -0,0 +1,100 @@
package com.wordpuzzle.hangul.ui.game
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
@Composable
fun StageEntranceBannerOverlay(
stageNumber: Int,
title: String,
onAnimationEnd: () -> Unit,
modifier: Modifier = Modifier
) {
val alphaAnim = remember { Animatable(0f) }
val rotationYAnim = remember { Animatable(90f) }
val scaleAnim = remember { Animatable(0.7f) }
LaunchedEffect(stageNumber) {
// 1. 3D Flip Wave Enter
alphaAnim.animateTo(1f, tween(300))
rotationYAnim.animateTo(0f, tween(500, easing = FastOutSlowInEasing))
scaleAnim.animateTo(1f, tween(500, easing = FastOutSlowInEasing))
delay(1200L)
// 2. Smooth Exit
alphaAnim.animateTo(0f, tween(400))
onAnimationEnd()
}
if (alphaAnim.value > 0f) {
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f * alphaAnim.value)),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.85f)
.graphicsLayer {
rotationY = rotationYAnim.value
scaleX = scaleAnim.value
scaleY = scaleAnim.value
alpha = alphaAnim.value
cameraDistance = 12f * density
},
shape = RoundedCornerShape(20.dp),
color = Color(0xFF1A237E), // 깊은 남색 한국적 한지 현판 배경
shadowElevation = 12.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp, horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = Color(0xFFFFD54F)
) {
Text(
text = "STAGE $stageNumber",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF1A237E)
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
}
}
}
}
}
@@ -0,0 +1,199 @@
package com.wordpuzzle.hangul.util
import android.content.Context
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.media.SoundPool
import com.wordpuzzle.hangul.R
import com.wordpuzzle.hangul.data.datastore.UserPreferencesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.Random
object SoundManager {
private var soundPool: SoundPool? = null
private var mediaPlayer: MediaPlayer? = null
private var currentBgmResId: Int = 0
private var soundEnabled: Boolean = true
private var soundTileClickId: Int = 0
private var soundTilePlaceId: Int = 0
private var soundCorrectId: Int = 0
private var soundStageClearId: Int = 0
private var soundWrongId: Int = 0
private var isInitialized = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val puzzleBgms = listOf(
R.raw.bgm_puzzle_1,
R.raw.bgm_puzzle_2,
R.raw.bgm_puzzle_3
)
fun init(context: Context, prefs: UserPreferencesManager) {
if (isInitialized) return
val appContext = context.applicationContext
// SoundPool 설정
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
soundPool = SoundPool.Builder()
.setMaxStreams(5)
.setAudioAttributes(audioAttributes)
.build()
soundPool?.let { pool ->
soundTileClickId = pool.load(appContext, R.raw.sfx_tile_click, 1)
soundTilePlaceId = pool.load(appContext, R.raw.sfx_tile_place, 1)
soundCorrectId = pool.load(appContext, R.raw.sfx_correct, 1)
soundStageClearId = pool.load(appContext, R.raw.sfx_stage_clear, 1)
soundWrongId = pool.load(appContext, R.raw.sfx_wrong, 1)
}
// 설정값 구독
scope.launch {
prefs.soundEnabledFlow.collectLatest { enabled ->
soundEnabled = enabled
if (!enabled) {
pauseBgm()
} else {
resumeBgm()
}
}
}
isInitialized = true
}
/**
* 메인 로비 화면 전용 BGM 재생
*/
fun playLobbyBgm(context: Context) {
playBgmResource(context, R.raw.bgm_lobby)
}
/**
* 퍼즐 화면 전용 BGM 재생 (10개 스테이지 단위로 퍼즐 BGM이 바뀜)
*/
fun playPuzzleBgm(context: Context, stageNumber: Int) {
val block = ((stageNumber - 1) / 10).coerceAtLeast(0)
val bgmIndex = Random(block.toLong() * 31L + 17L).nextInt(puzzleBgms.size)
val targetResId = puzzleBgms[bgmIndex]
playBgmResource(context, targetResId)
}
fun playBgm(context: Context) {
playLobbyBgm(context)
}
private fun playBgmResource(context: Context, resId: Int) {
if (!soundEnabled) {
currentBgmResId = resId
return
}
if (currentBgmResId == resId && mediaPlayer != null) {
mediaPlayer?.let { player ->
if (!player.isPlaying) {
try {
player.start()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return
}
stopBgm()
currentBgmResId = resId
mediaPlayer = MediaPlayer.create(context.applicationContext, resId)?.apply {
isLooping = true
setVolume(0.5f, 0.5f)
}
mediaPlayer?.let { player ->
try {
player.start()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun pauseBgm() {
mediaPlayer?.let { player ->
if (player.isPlaying) {
player.pause()
}
}
}
fun resumeBgm() {
if (!soundEnabled) return
mediaPlayer?.let { player ->
if (!player.isPlaying) {
try {
player.start()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
fun stopBgm() {
mediaPlayer?.let { player ->
try {
if (player.isPlaying) {
player.stop()
}
player.release()
} catch (e: Exception) {
e.printStackTrace()
}
}
mediaPlayer = null
currentBgmResId = 0
}
fun playTileClick() {
playSound(soundTileClickId)
}
fun playTilePlace() {
playSound(soundTilePlaceId)
}
fun playCorrect() {
playSound(soundCorrectId)
}
fun playStageClear() {
playSound(soundStageClearId)
}
fun playWrong() {
playSound(soundWrongId)
}
private fun playSound(soundId: Int) {
if (!soundEnabled || soundId == 0) return
soundPool?.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f)
}
fun release() {
stopBgm()
soundPool?.release()
soundPool = null
isInitialized = false
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.