feat: flying card animation - cards animate from hand to discard pile
This commit is contained in:
@@ -16,18 +16,158 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.uno.game.GameEngine
|
||||
import com.uno.game.model.*
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// region --- Flying Card Animation ---
|
||||
|
||||
private data class FlyingCard(
|
||||
val card: Card,
|
||||
val fromX: Float,
|
||||
val fromY: Float,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun rememberFlyingCardState(): FlyingCardState {
|
||||
return remember { FlyingCardState() }
|
||||
}
|
||||
|
||||
private class FlyingCardState {
|
||||
var flyingCard by mutableStateOf<FlyingCard?>(null)
|
||||
private set
|
||||
|
||||
private var animatableX = Animatable(0f)
|
||||
private var animatableY = Animatable(0f)
|
||||
private var animatableScale = Animatable(0f)
|
||||
private var animatableRotation = Animatable(0f)
|
||||
|
||||
val offsetX: Float get() = animatableX.value
|
||||
val offsetY: Float get() = animatableY.value
|
||||
val scale: Float get() = animatableScale.value
|
||||
val rotation: Float get() = animatableRotation.value
|
||||
|
||||
suspend fun animateCard(card: Card, fromX: Float, fromY: Float) {
|
||||
flyingCard = FlyingCard(card, fromX, fromY)
|
||||
|
||||
animatableX = Animatable(fromX)
|
||||
animatableY = Animatable(fromY)
|
||||
animatableScale = Animatable(0.3f)
|
||||
animatableRotation = Animatable((Math.random() * 60 - 30).toFloat())
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
animatableX.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow)
|
||||
)
|
||||
}
|
||||
launch {
|
||||
animatableY.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow)
|
||||
)
|
||||
}
|
||||
launch {
|
||||
animatableScale.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium)
|
||||
)
|
||||
}
|
||||
launch {
|
||||
animatableRotation.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessLow)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
delay(200)
|
||||
animatableScale.animateTo(
|
||||
targetValue = 0.8f,
|
||||
animationSpec = tween(200, easing = FastOutSlowInEasing)
|
||||
)
|
||||
flyingCard = null
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
flyingCard = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FlyingCardOverlay(
|
||||
state: FlyingCardState,
|
||||
screenCenterX: Float,
|
||||
screenCenterY: Float
|
||||
) {
|
||||
val card = state.flyingCard ?: return
|
||||
|
||||
val backgroundColor = when (card.card.color) {
|
||||
CardColor.RED -> Color(0xFFE53935)
|
||||
CardColor.GREEN -> Color(0xFF43A047)
|
||||
CardColor.BLUE -> Color(0xFF1E88E5)
|
||||
CardColor.YELLOW -> Color(0xFFFDD835)
|
||||
null -> Color(0xFF424242)
|
||||
}
|
||||
val textColor = if (card.card.color == CardColor.YELLOW) Color.Black else Color.White
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) { /* consume touch events */ },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = with(LocalDensity.current) { state.offsetX.toDp() },
|
||||
y = with(LocalDensity.current) { state.offsetY.toDp() }
|
||||
)
|
||||
.graphicsLayer {
|
||||
scaleX = state.scale
|
||||
scaleY = state.scale
|
||||
rotationZ = state.rotation
|
||||
shadowElevation = 16f
|
||||
}
|
||||
.size(width = 60.dp, height = 90.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(8.dp))
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(backgroundColor)
|
||||
.border(2.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(8.dp))
|
||||
.padding(6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = card.card.displayValue,
|
||||
color = textColor,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (card.card.color != null) {
|
||||
Text(text = card.card.color.displayName, color = textColor, fontSize = 9.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region --- Animations ---
|
||||
|
||||
@@ -130,10 +270,44 @@ fun UnoGameScreen(
|
||||
) {
|
||||
var showColorPicker by remember { mutableStateOf(false) }
|
||||
var pendingWildCard by remember { mutableStateOf<Card?>(null) }
|
||||
var previousTopCard by remember { mutableStateOf(state.deck.topCard) }
|
||||
var showUnoCall by remember { mutableStateOf(false) }
|
||||
var previousLastAction by remember { mutableStateOf(state.lastAction) }
|
||||
var previousTopCard by remember { mutableStateOf(state.deck.topCard) }
|
||||
var lastPlayedByPlayerIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
val flyingState = rememberFlyingCardState()
|
||||
|
||||
// Track who played the last card
|
||||
LaunchedEffect(state.currentPlayerIndex, state.deck.topCard) {
|
||||
val newTop = state.deck.topCard
|
||||
if (newTop != null && newTop != previousTopCard) {
|
||||
// The card was played by the player whose turn it was BEFORE the index changed
|
||||
val fromX = when (lastPlayedByPlayerIndex) {
|
||||
0 -> 0f
|
||||
1 -> 300f
|
||||
2 -> 0f
|
||||
3 -> -300f
|
||||
else -> 0f
|
||||
}
|
||||
val fromY = when (lastPlayedByPlayerIndex) {
|
||||
0 -> 400f
|
||||
1 -> -200f
|
||||
2 -> -400f
|
||||
3 -> -200f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
flyingState.animateCard(newTop, fromX, fromY)
|
||||
previousTopCard = newTop
|
||||
}
|
||||
}
|
||||
|
||||
// Update who is about to play
|
||||
LaunchedEffect(state.currentPlayerIndex) {
|
||||
lastPlayedByPlayerIndex = state.currentPlayerIndex
|
||||
}
|
||||
|
||||
// UNO-Rufen Animation
|
||||
LaunchedEffect(state.lastAction) {
|
||||
if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) {
|
||||
showUnoCall = true
|
||||
@@ -143,10 +317,6 @@ fun UnoGameScreen(
|
||||
previousLastAction = state.lastAction
|
||||
}
|
||||
|
||||
LaunchedEffect(state.deck.topCard) {
|
||||
previousTopCard = state.deck.topCard
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -166,6 +336,7 @@ fun UnoGameScreen(
|
||||
currentColor = state.currentColor,
|
||||
isCurrentTurn = state.currentPlayerIndex == 0,
|
||||
onCardClick = { card ->
|
||||
lastPlayedByPlayerIndex = 0
|
||||
if (card.isWild) {
|
||||
pendingWildCard = card
|
||||
showColorPicker = true
|
||||
@@ -196,6 +367,13 @@ fun UnoGameScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Fliegende Karte Overlay
|
||||
FlyingCardOverlay(
|
||||
state = flyingState,
|
||||
screenCenterX = 0f,
|
||||
screenCenterY = 0f
|
||||
)
|
||||
|
||||
// UNO call overlay
|
||||
AnimatedVisibility(
|
||||
visible = showUnoCall,
|
||||
@@ -261,28 +439,17 @@ private fun GameInfoBar(state: GameState) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Richtung:",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text("Richtung:", style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "↗️",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = directionRotation
|
||||
}
|
||||
modifier = Modifier.graphicsLayer { rotationZ = directionRotation }
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Stapel: ${state.deck.drawPile.size}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text("Stapel: ${state.deck.drawPile.size}", style = MaterialTheme.typography.bodyMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Farbe: ",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text("Farbe: ", style = MaterialTheme.typography.bodyMedium)
|
||||
state.currentColor?.let { color ->
|
||||
val bgColor = when (color) {
|
||||
CardColor.RED -> Color(0xFFE53935)
|
||||
@@ -291,10 +458,7 @@ private fun GameInfoBar(state: GameState) {
|
||||
CardColor.YELLOW -> Color(0xFFFDD835)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(bgColor)
|
||||
modifier = Modifier.size(14.dp).clip(RoundedCornerShape(3.dp)).background(bgColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -328,10 +492,7 @@ private fun OpponentHandView(player: Player, isActive: Boolean) {
|
||||
) {
|
||||
if (isActive) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
modifier = Modifier.size(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
@@ -339,18 +500,14 @@ private fun OpponentHandView(player: Player, isActive: Boolean) {
|
||||
text = player.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Bold,
|
||||
color = if (isActive) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row {
|
||||
repeat(minOf(player.hand.size, 5)) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = scaleIn(
|
||||
initialScale = 0f,
|
||||
animationSpec = tween(200, delayMillis = it * 50)
|
||||
)
|
||||
enter = scaleIn(initialScale = 0f, animationSpec = tween(200, delayMillis = it * 50))
|
||||
) {
|
||||
CardBack()
|
||||
}
|
||||
@@ -531,8 +688,7 @@ private fun PlayerHand(
|
||||
text = "${player.name} (${player.hand.size} Karten)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isCurrentTurn) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
color = if (isCurrentTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
@@ -589,8 +745,7 @@ private fun ActionButtons(
|
||||
enabled = isMyTurn && hasTwoCards && !(player?.hasCalledUno ?: true),
|
||||
modifier = Modifier.scale(unoScale),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (hasTwoCards) Color(0xFFFF6D00)
|
||||
else MaterialTheme.colorScheme.primary
|
||||
containerColor = if (hasTwoCards) Color(0xFFFF6D00) else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("UNO!")
|
||||
@@ -606,62 +761,35 @@ private fun ActionButtons(
|
||||
fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
val celebrationScale by infiniteTransition.animateFloat(
|
||||
initialValue = 0.8f,
|
||||
targetValue = 1.1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(500, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
)
|
||||
initialValue = 0.8f, targetValue = 1.1f,
|
||||
animationSpec = infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse)
|
||||
)
|
||||
val celebrationRotation by infiniteTransition.animateFloat(
|
||||
initialValue = -3f,
|
||||
targetValue = 3f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(400, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
)
|
||||
initialValue = -3f, targetValue = 3f,
|
||||
animationSpec = infiniteRepeatable(tween(400, easing = FastOutSlowInEasing), RepeatMode.Reverse)
|
||||
)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "🎉 Gewonnen! 🎉",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = celebrationScale
|
||||
scaleY = celebrationScale
|
||||
rotationZ = celebrationRotation
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
text = "Gewonnen!",
|
||||
modifier = Modifier.fillMaxWidth().graphicsLayer {
|
||||
scaleX = celebrationScale; scaleY = celebrationScale; rotationZ = celebrationRotation
|
||||
},
|
||||
textAlign = TextAlign.Center, fontSize = 28.sp, fontWeight = FontWeight.ExtraBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = winner?.name ?: "Unbekannt",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(winner?.name ?: "?", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Herzlichen Glückwunsch!", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.scale(celebrationScale)
|
||||
) {
|
||||
Text("Nochmal spielen")
|
||||
}
|
||||
Button(onClick = onDismiss, modifier = Modifier.scale(celebrationScale)) { Text("Nochmal spielen") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -681,11 +809,8 @@ fun AnimatedColorPickerDialog(
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn()
|
||||
) {
|
||||
Text("Farbe wählen")
|
||||
AnimatedVisibility(visible = visible, enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn()) {
|
||||
Text("Farbe wahlen")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
@@ -701,27 +826,15 @@ fun AnimatedColorPickerDialog(
|
||||
CardColor.BLUE -> Color(0xFF1E88E5)
|
||||
CardColor.YELLOW -> Color(0xFFFDD835)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = scaleIn(
|
||||
initialScale = 0f,
|
||||
animationSpec = tween(300, delayMillis = index * 80, easing = FastOutSlowInEasing)
|
||||
) + fadeIn(tween(300, delayMillis = index * 80))
|
||||
enter = scaleIn(initialScale = 0f, animationSpec = tween(300, delayMillis = index * 80, easing = FastOutSlowInEasing)) + fadeIn(tween(300, delayMillis = index * 80))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(bgColor)
|
||||
.clickable { onColorSelected(color) },
|
||||
modifier = Modifier.size(60.dp).clip(RoundedCornerShape(8.dp)).background(bgColor).clickable { onColorSelected(color) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = color.displayName,
|
||||
color = if (color == CardColor.YELLOW) Color.Black else Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(color.displayName, color = if (color == CardColor.YELLOW) Color.Black else Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user