feat: flying card animation - cards animate from hand to discard pile

This commit is contained in:
2026-08-02 18:37:47 +02:00
parent 2187bf2f47
commit a7fb6ff94c
@@ -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)
}
}
}