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.scale
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.uno.game.GameEngine import com.uno.game.GameEngine
import com.uno.game.model.* 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 --- // region --- Animations ---
@@ -130,10 +270,44 @@ fun UnoGameScreen(
) { ) {
var showColorPicker by remember { mutableStateOf(false) } var showColorPicker by remember { mutableStateOf(false) }
var pendingWildCard by remember { mutableStateOf<Card?>(null) } var pendingWildCard by remember { mutableStateOf<Card?>(null) }
var previousTopCard by remember { mutableStateOf(state.deck.topCard) }
var showUnoCall by remember { mutableStateOf(false) } var showUnoCall by remember { mutableStateOf(false) }
var previousLastAction by remember { mutableStateOf(state.lastAction) } 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) { LaunchedEffect(state.lastAction) {
if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) { if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) {
showUnoCall = true showUnoCall = true
@@ -143,10 +317,6 @@ fun UnoGameScreen(
previousLastAction = state.lastAction previousLastAction = state.lastAction
} }
LaunchedEffect(state.deck.topCard) {
previousTopCard = state.deck.topCard
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -166,6 +336,7 @@ fun UnoGameScreen(
currentColor = state.currentColor, currentColor = state.currentColor,
isCurrentTurn = state.currentPlayerIndex == 0, isCurrentTurn = state.currentPlayerIndex == 0,
onCardClick = { card -> onCardClick = { card ->
lastPlayedByPlayerIndex = 0
if (card.isWild) { if (card.isWild) {
pendingWildCard = card pendingWildCard = card
showColorPicker = true showColorPicker = true
@@ -196,6 +367,13 @@ fun UnoGameScreen(
} }
} }
// Fliegende Karte Overlay
FlyingCardOverlay(
state = flyingState,
screenCenterX = 0f,
screenCenterY = 0f
)
// UNO call overlay // UNO call overlay
AnimatedVisibility( AnimatedVisibility(
visible = showUnoCall, visible = showUnoCall,
@@ -261,28 +439,17 @@ private fun GameInfoBar(state: GameState) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row(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)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = "↗️", text = "↗️",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer { rotationZ = directionRotation }
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) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text("Farbe: ", style = MaterialTheme.typography.bodyMedium)
text = "Farbe: ",
style = MaterialTheme.typography.bodyMedium
)
state.currentColor?.let { color -> state.currentColor?.let { color ->
val bgColor = when (color) { val bgColor = when (color) {
CardColor.RED -> Color(0xFFE53935) CardColor.RED -> Color(0xFFE53935)
@@ -291,10 +458,7 @@ private fun GameInfoBar(state: GameState) {
CardColor.YELLOW -> Color(0xFFFDD835) CardColor.YELLOW -> Color(0xFFFDD835)
} }
Box( Box(
modifier = Modifier modifier = Modifier.size(14.dp).clip(RoundedCornerShape(3.dp)).background(bgColor)
.size(14.dp)
.clip(RoundedCornerShape(3.dp))
.background(bgColor)
) )
} }
} }
@@ -328,10 +492,7 @@ private fun OpponentHandView(player: Player, isActive: Boolean) {
) { ) {
if (isActive) { if (isActive) {
Box( Box(
modifier = Modifier modifier = Modifier.size(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.primary)
.size(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary)
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
} }
@@ -339,18 +500,14 @@ private fun OpponentHandView(player: Player, isActive: Boolean) {
text = player.name, text = player.name,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Bold, fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Bold,
color = if (isActive) MaterialTheme.colorScheme.primary color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Row { Row {
repeat(minOf(player.hand.size, 5)) { repeat(minOf(player.hand.size, 5)) {
AnimatedVisibility( AnimatedVisibility(
visible = true, visible = true,
enter = scaleIn( enter = scaleIn(initialScale = 0f, animationSpec = tween(200, delayMillis = it * 50))
initialScale = 0f,
animationSpec = tween(200, delayMillis = it * 50)
)
) { ) {
CardBack() CardBack()
} }
@@ -531,8 +688,7 @@ private fun PlayerHand(
text = "${player.name} (${player.hand.size} Karten)", text = "${player.name} (${player.hand.size} Karten)",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (isCurrentTurn) MaterialTheme.colorScheme.primary color = if (isCurrentTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurface
) )
} }
@@ -589,8 +745,7 @@ private fun ActionButtons(
enabled = isMyTurn && hasTwoCards && !(player?.hasCalledUno ?: true), enabled = isMyTurn && hasTwoCards && !(player?.hasCalledUno ?: true),
modifier = Modifier.scale(unoScale), modifier = Modifier.scale(unoScale),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = if (hasTwoCards) Color(0xFFFF6D00) containerColor = if (hasTwoCards) Color(0xFFFF6D00) else MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.primary
) )
) { ) {
Text("UNO!") Text("UNO!")
@@ -606,62 +761,35 @@ private fun ActionButtons(
fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) { fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) {
val infiniteTransition = rememberInfiniteTransition() val infiniteTransition = rememberInfiniteTransition()
val celebrationScale by infiniteTransition.animateFloat( val celebrationScale by infiniteTransition.animateFloat(
initialValue = 0.8f, initialValue = 0.8f, targetValue = 1.1f,
targetValue = 1.1f, animationSpec = infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse)
animationSpec = infiniteRepeatable(
animation = tween(500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
) )
val celebrationRotation by infiniteTransition.animateFloat( val celebrationRotation by infiniteTransition.animateFloat(
initialValue = -3f, initialValue = -3f, targetValue = 3f,
targetValue = 3f, animationSpec = infiniteRepeatable(tween(400, easing = FastOutSlowInEasing), RepeatMode.Reverse)
animationSpec = infiniteRepeatable(
animation = tween(400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
) )
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = {
Text( Text(
text = "🎉 Gewonnen! 🎉", text = "Gewonnen!",
modifier = Modifier modifier = Modifier.fillMaxWidth().graphicsLayer {
.fillMaxWidth() scaleX = celebrationScale; scaleY = celebrationScale; rotationZ = celebrationRotation
.graphicsLayer {
scaleX = celebrationScale
scaleY = celebrationScale
rotationZ = celebrationRotation
}, },
textAlign = TextAlign.Center, textAlign = TextAlign.Center, fontSize = 28.sp, fontWeight = FontWeight.ExtraBold,
fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
}, },
text = { text = {
Column( Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
modifier = Modifier.fillMaxWidth(), Text(winner?.name ?: "?", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = winner?.name ?: "Unbekannt",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text("Herzlichen Glückwunsch!", style = MaterialTheme.typography.bodyLarge) Text("Herzlichen Glückwunsch!", style = MaterialTheme.typography.bodyLarge)
} }
}, },
confirmButton = { confirmButton = {
Button( Button(onClick = onDismiss, modifier = Modifier.scale(celebrationScale)) { Text("Nochmal spielen") }
onClick = onDismiss,
modifier = Modifier.scale(celebrationScale)
) {
Text("Nochmal spielen")
}
} }
) )
} }
@@ -681,11 +809,8 @@ fun AnimatedColorPickerDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = {
AnimatedVisibility( AnimatedVisibility(visible = visible, enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn()) {
visible = visible, Text("Farbe wahlen")
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn()
) {
Text("Farbe wählen")
} }
}, },
text = { text = {
@@ -701,27 +826,15 @@ fun AnimatedColorPickerDialog(
CardColor.BLUE -> Color(0xFF1E88E5) CardColor.BLUE -> Color(0xFF1E88E5)
CardColor.YELLOW -> Color(0xFFFDD835) CardColor.YELLOW -> Color(0xFFFDD835)
} }
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = scaleIn( enter = scaleIn(initialScale = 0f, animationSpec = tween(300, delayMillis = index * 80, easing = FastOutSlowInEasing)) + fadeIn(tween(300, delayMillis = index * 80))
initialScale = 0f,
animationSpec = tween(300, delayMillis = index * 80, easing = FastOutSlowInEasing)
) + fadeIn(tween(300, delayMillis = index * 80))
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier.size(60.dp).clip(RoundedCornerShape(8.dp)).background(bgColor).clickable { onColorSelected(color) },
.size(60.dp)
.clip(RoundedCornerShape(8.dp))
.background(bgColor)
.clickable { onColorSelected(color) },
contentAlignment = Alignment.Center 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
)
} }
} }
} }