유틸리티 및 화면 플립 효과 추가
This commit is contained in:
@@ -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.
Reference in New Issue
Block a user