diff --git a/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt b/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt index 32ee4ac..84abab7 100644 --- a/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt +++ b/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt @@ -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(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(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) } } }