diff --git a/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt b/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt index 84abab7..6a44842 100644 --- a/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt +++ b/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt @@ -15,159 +15,16 @@ import androidx.compose.ui.Modifier 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.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.dp import androidx.compose.ui.unit.sp import com.uno.game.GameEngine import com.uno.game.model.* -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 --- @@ -175,12 +32,8 @@ private fun FlyingCardOverlay( fun rememberPulseAnimation(): Float { val infiniteTransition = rememberInfiniteTransition() val pulse by infiniteTransition.animateFloat( - initialValue = 0.8f, - targetValue = 1.2f, - animationSpec = infiniteRepeatable( - animation = tween(600, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ) + initialValue = 0.8f, targetValue = 1.2f, + animationSpec = infiniteRepeatable(tween(600, easing = FastOutSlowInEasing), RepeatMode.Reverse) ) return pulse } @@ -189,73 +42,98 @@ fun rememberPulseAnimation(): Float { fun rememberGlowAnimation(): Float { val infiniteTransition = rememberInfiniteTransition() val glow by infiniteTransition.animateFloat( - initialValue = 0.3f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(800, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ) + initialValue = 0.3f, targetValue = 1f, + animationSpec = infiniteRepeatable(tween(800, easing = FastOutSlowInEasing), RepeatMode.Reverse) ) return glow } @Composable fun AnimatedCardEntry( - card: Card, - onClick: () -> Unit, - modifier: Modifier = Modifier, - isPlayable: Boolean = true, - animationDelay: Int = 0 + card: Card, onClick: () -> Unit, modifier: Modifier = Modifier, + isPlayable: Boolean = true, animationDelay: Int = 0 ) { var visible by remember { mutableStateOf(false) } LaunchedEffect(card) { visible = false - kotlinx.coroutines.delay(animationDelay.toLong()) + delay(animationDelay.toLong()) visible = true } - AnimatedVisibility( visible = visible, - enter = scaleIn( - initialScale = 0f, - animationSpec = tween(300, delayMillis = animationDelay, easing = FastOutSlowInEasing) - ) + fadeIn(animationSpec = tween(300, delayMillis = animationDelay)) + enter = scaleIn(initialScale = 0f, animationSpec = tween(300, delayMillis = animationDelay, easing = FastOutSlowInEasing)) + + fadeIn(animationSpec = tween(300, delayMillis = animationDelay)) ) { UnoCardView(card = card, onClick = onClick, modifier = modifier, isPlayable = isPlayable) } } @Composable -fun AnimatedTopCard(card: Card) { - var prevState by remember { mutableStateOf(card) } - var playAnim by remember { mutableStateOf(false) } +fun FlyingTopCard( + card: Card, + fromBottom: Boolean, + key: Any +) { + var hasAnimated by remember(key) { mutableStateOf(false) } - LaunchedEffect(card) { - if (card != prevState) { - playAnim = true - kotlinx.coroutines.delay(400) - playAnim = false - prevState = card - } + LaunchedEffect(key) { + hasAnimated = false + delay(50) + hasAnimated = true } - val scale by animateFloatAsState( - targetValue = if (playAnim) 1.3f else 1f, - animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessLow) + val animatedOffsetY by animateFloatAsState( + targetValue = if (hasAnimated) 0f else if (fromBottom) 300f else -300f, + animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium), + label = "flyY" ) - val rotation by animateFloatAsState( - targetValue = if (playAnim) 15f else 0f, - animationSpec = tween(400, easing = FastOutSlowInEasing) + val animatedScale by animateFloatAsState( + targetValue = if (hasAnimated) 1f else 0.4f, + animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium), + label = "flyScale" ) + val animatedRotation by animateFloatAsState( + targetValue = 0f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow), + label = "flyRotation" + ) + val initialRotation = remember(key) { (Math.random() * 40 - 20).toFloat() } + + val backgroundColor = when (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.color == CardColor.YELLOW) Color.Black else Color.White Box( - modifier = Modifier.graphicsLayer { - scaleX = scale - scaleY = scale - rotationZ = rotation - } + modifier = Modifier + .graphicsLayer { + translationY = animatedOffsetY + scaleX = animatedScale + scaleY = animatedScale + rotationZ = if (hasAnimated) animatedRotation else initialRotation + shadowElevation = if (hasAnimated) 8f else 20f + } + .size(width = 55.dp, height = 82.dp) + .shadow(10.dp, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(8.dp)) + .padding(5.dp), + contentAlignment = Alignment.Center ) { - UnoCardView(card = card, onClick = {}) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(card.displayValue, color = textColor, fontSize = 20.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + if (card.color != null) { + Text(card.color.displayName, color = textColor, fontSize = 8.sp) + } + } } } @@ -274,44 +152,27 @@ fun UnoGameScreen( var previousLastAction by remember { mutableStateOf(state.lastAction) } var previousTopCard by remember { mutableStateOf(state.deck.topCard) } var lastPlayedByPlayerIndex by remember { mutableIntStateOf(0) } + var flyingCardKey by remember { mutableIntStateOf(0) } - val flyingState = rememberFlyingCardState() - - // Track who played the last card - LaunchedEffect(state.currentPlayerIndex, state.deck.topCard) { + // Detect when top card changes → trigger flying animation + LaunchedEffect(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) + flyingCardKey++ previousTopCard = newTop } } - // Update who is about to play + // Track who is about to play LaunchedEffect(state.currentPlayerIndex) { lastPlayedByPlayerIndex = state.currentPlayerIndex } - // UNO-Rufen Animation + // UNO call overlay LaunchedEffect(state.lastAction) { if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) { showUnoCall = true - kotlinx.coroutines.delay(1500) + delay(1500) showUnoCall = false } previousLastAction = state.lastAction @@ -319,16 +180,14 @@ fun UnoGameScreen( Box(modifier = Modifier.fillMaxSize()) { Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { GameInfoBar(state) Spacer(modifier = Modifier.height(12.dp)) OpponentHands(state) Spacer(modifier = Modifier.height(12.dp)) - DrawPileAndTopCard(state, onDrawCard) + DrawPileAndTopCard(state, onDrawCard, lastPlayedByPlayerIndex, flyingCardKey) Spacer(modifier = Modifier.height(12.dp)) PlayerHand( player = state.players.firstOrNull(), @@ -357,50 +216,28 @@ fun UnoGameScreen( label = "action" ) { action -> if (action.isNotEmpty()) { - Text( - text = action, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 8.dp) - ) + Text(action, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp)) } } } - // Fliegende Karte Overlay - FlyingCardOverlay( - state = flyingState, - screenCenterX = 0f, - screenCenterY = 0f - ) - // UNO call overlay AnimatedVisibility( visible = showUnoCall, - enter = scaleIn( - initialScale = 0f, - animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessLow) - ) + fadeIn(), + enter = scaleIn(initialScale = 0f, animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessLow)) + fadeIn(), exit = scaleOut(targetScale = 2f) + fadeOut(), modifier = Modifier.align(Alignment.Center) ) { Text( - text = "UNO!", - fontSize = 72.sp, - fontWeight = FontWeight.ExtraBold, - color = Color(0xFFFF6D00), - modifier = Modifier - .shadow(16.dp, RoundedCornerShape(16.dp)) + "UNO!", fontSize = 72.sp, fontWeight = FontWeight.ExtraBold, color = Color(0xFFFF6D00), + modifier = Modifier.shadow(16.dp, RoundedCornerShape(16.dp)) .background(Color.White.copy(alpha = 0.9f), RoundedCornerShape(16.dp)) .padding(horizontal = 32.dp, vertical = 16.dp) ) } - // Winner celebration overlay if (state.isFinished) { - WinnerCelebration(state.winner) { - // dismiss handled by caller - } + WinnerCelebration(state.winner) {} } } @@ -411,10 +248,7 @@ fun UnoGameScreen( showColorPicker = false pendingWildCard = null }, - onDismiss = { - showColorPicker = false - pendingWildCard = null - } + onDismiss = { showColorPicker = false; pendingWildCard = null } ) } } @@ -425,41 +259,21 @@ fun UnoGameScreen( private fun GameInfoBar(state: GameState) { val directionRotation by animateFloatAsState( targetValue = if (state.direction == GameDirection.CLOCKWISE) 0f else 180f, - animationSpec = tween(500, easing = FastOutSlowInEasing), - label = "direction" + animationSpec = tween(500, easing = FastOutSlowInEasing), label = "direction" ) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Richtung:", style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "↗️", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.graphicsLayer { rotationZ = directionRotation } - ) + Text("↗️", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.graphicsLayer { rotationZ = directionRotation }) } Text("Stapel: ${state.deck.drawPile.size}", style = MaterialTheme.typography.bodyMedium) Row(verticalAlignment = Alignment.CenterVertically) { Text("Farbe: ", style = MaterialTheme.typography.bodyMedium) state.currentColor?.let { color -> - val bgColor = when (color) { - CardColor.RED -> Color(0xFFE53935) - CardColor.GREEN -> Color(0xFF43A047) - CardColor.BLUE -> Color(0xFF1E88E5) - CardColor.YELLOW -> Color(0xFFFDD835) - } - Box( - modifier = Modifier.size(14.dp).clip(RoundedCornerShape(3.dp)).background(bgColor) - ) + val bgColor = when (color) { CardColor.RED -> Color(0xFFE53935); CardColor.GREEN -> Color(0xFF43A047); CardColor.BLUE -> Color(0xFF1E88E5); CardColor.YELLOW -> Color(0xFFFDD835) } + Box(modifier = Modifier.size(14.dp).clip(RoundedCornerShape(3.dp)).background(bgColor)) } } } @@ -472,10 +286,7 @@ private fun GameInfoBar(state: GameState) { @Composable private fun OpponentHands(state: GameState) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { state.players.drop(1).forEach { player -> OpponentHandView(player, isActive = state.currentPlayerIndex == state.players.indexOf(player)) } @@ -485,58 +296,31 @@ private fun OpponentHands(state: GameState) { @Composable private fun OpponentHandView(player: Player, isActive: Boolean) { val pulse = if (isActive) rememberPulseAnimation() else 1f - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.scale(pulse) - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.scale(pulse)) { if (isActive) { - Box( - modifier = Modifier.size(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.primary) - ) + Box(modifier = Modifier.size(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.primary)) Spacer(modifier = Modifier.height(2.dp)) } - Text( - 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 - ) + 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) 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)) - ) { - CardBack() - } - } - if (player.hand.size > 5) { - Text("+${player.hand.size - 5}", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp)) + AnimatedVisibility(visible = true, enter = scaleIn(initialScale = 0f, animationSpec = tween(200, delayMillis = it * 50))) { CardBack() } } + if (player.hand.size > 5) { Text("+${player.hand.size - 5}", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp)) } } - Text( - text = "${player.hand.size} Karten", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text("${player.hand.size} Karten", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @Composable private fun CardBack() { Box( - modifier = Modifier - .size(width = 30.dp, height = 45.dp) - .clip(RoundedCornerShape(4.dp)) - .background(Color(0xFF1A237E)) - .border(1.dp, Color.White, RoundedCornerShape(4.dp)) - .padding(2.dp), + modifier = Modifier.size(width = 30.dp, height = 45.dp).clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF1A237E)).border(1.dp, Color.White, RoundedCornerShape(4.dp)).padding(2.dp), contentAlignment = Alignment.Center - ) { - Text("UNO", color = Color.White, fontSize = 7.sp, fontWeight = FontWeight.Bold) - } + ) { Text("UNO", color = Color.White, fontSize = 7.sp, fontWeight = FontWeight.Bold) } } // endregion @@ -544,32 +328,19 @@ private fun CardBack() { // region --- DrawPileAndTopCard --- @Composable -private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit) { +private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit, lastPlayedByPlayerIndex: Int, flyingCardKey: Int) { var drawHover by remember { mutableStateOf(false) } - val drawScale by animateFloatAsState( - targetValue = if (drawHover) 1.1f else 1f, - animationSpec = spring(dampingRatio = 0.5f) - ) + val drawScale by animateFloatAsState(targetValue = if (drawHover) 1.1f else 1f, animationSpec = spring(dampingRatio = 0.5f)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .scale(drawScale) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - drawHover = true - tryAwaitRelease() - drawHover = false - }, - onTap = { onDrawCard() } - ) - } + modifier = Modifier.scale(drawScale).pointerInput(Unit) { + detectTapGestures( + onPress = { drawHover = true; tryAwaitRelease(); drawHover = false }, + onTap = { onDrawCard() } + ) + } ) { CardBack() Spacer(modifier = Modifier.height(4.dp)) @@ -579,7 +350,8 @@ private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit) { Spacer(modifier = Modifier.width(32.dp)) state.deck.topCard?.let { card -> - AnimatedTopCard(card = card) + val fromBottom = lastPlayedByPlayerIndex == 0 + FlyingTopCard(card = card, fromBottom = fromBottom, key = flyingCardKey) } } } @@ -589,63 +361,23 @@ private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit) { // region --- UnoCardView --- @Composable -fun UnoCardView( - card: Card, - onClick: () -> Unit, - modifier: Modifier = Modifier, - isPlayable: Boolean = true -) { +fun UnoCardView(card: Card, onClick: () -> Unit, modifier: Modifier = Modifier, isPlayable: Boolean = true) { val glowAlpha = if (isPlayable) rememberGlowAnimation() else 0f - - val backgroundColor = when (card.color) { - CardColor.RED -> Color(0xFFE53935) - CardColor.GREEN -> Color(0xFF43A047) - CardColor.BLUE -> Color(0xFF1E88E5) - CardColor.YELLOW -> Color(0xFFFDD835) - null -> Color(0xFF424242) - } + val backgroundColor = when (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.color == CardColor.YELLOW) Color.Black else Color.White Box( - modifier = modifier - .size(width = 50.dp, height = 75.dp) - .shadow( - elevation = if (isPlayable) (4 * glowAlpha).dp else 2.dp, - shape = RoundedCornerShape(6.dp), - ambientColor = backgroundColor, - spotColor = backgroundColor - ) - .clip(RoundedCornerShape(6.dp)) - .background(backgroundColor) - .then( - if (isPlayable) { - Modifier.border( - width = (1.5f + glowAlpha * 1.5f).dp, - color = Color.White.copy(alpha = glowAlpha * 0.8f), - shape = RoundedCornerShape(6.dp) - ) - } else { - Modifier.border(2.dp, Color.Gray.copy(alpha = 0.5f), RoundedCornerShape(6.dp)) - } - ) - .clickable(enabled = isPlayable) { onClick() } - .padding(4.dp), + modifier = modifier.size(width = 50.dp, height = 75.dp) + .shadow(if (isPlayable) (4 * glowAlpha).dp else 2.dp, RoundedCornerShape(6.dp), ambientColor = backgroundColor, spotColor = backgroundColor) + .clip(RoundedCornerShape(6.dp)).background(backgroundColor) + .then(if (isPlayable) Modifier.border((1.5f + glowAlpha * 1.5f).dp, Color.White.copy(alpha = glowAlpha * 0.8f), RoundedCornerShape(6.dp)) + else Modifier.border(2.dp, Color.Gray.copy(alpha = 0.5f), RoundedCornerShape(6.dp))) + .clickable(enabled = isPlayable) { onClick() }.padding(4.dp), contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = card.displayValue, - color = textColor, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - if (card.color != null) { - Text(text = card.color.displayName, color = textColor, fontSize = 7.sp) - } + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(card.displayValue, color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + if (card.color != null) { Text(card.color.displayName, color = textColor, fontSize = 7.sp) } } } } @@ -655,59 +387,27 @@ fun UnoCardView( // region --- PlayerHand --- @Composable -private fun PlayerHand( - player: Player?, - topCard: Card?, - currentColor: CardColor?, - isCurrentTurn: Boolean, - onCardClick: (Card) -> Unit -) { +private fun PlayerHand(player: Player?, topCard: Card?, currentColor: CardColor?, isCurrentTurn: Boolean, onCardClick: (Card) -> Unit) { if (player == null) return + val turnScale by animateFloatAsState(targetValue = if (isCurrentTurn) 1.02f else 1f, animationSpec = spring(dampingRatio = 0.6f)) - val turnScale by animateFloatAsState( - targetValue = if (isCurrentTurn) 1.02f else 1f, - animationSpec = spring(dampingRatio = 0.6f) - ) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.scale(turnScale) - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.scale(turnScale)) { Row(verticalAlignment = Alignment.CenterVertically) { if (isCurrentTurn) { val dotPulse = rememberPulseAnimation() - Box( - modifier = Modifier - .size((8 * dotPulse).dp) - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colorScheme.primary) - ) + Box(modifier = Modifier.size((8 * dotPulse).dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.primary)) Spacer(modifier = Modifier.width(6.dp)) } - Text( - text = "${player.name} (${player.hand.size} Karten)", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = if (isCurrentTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - ) + Text("${player.name} (${player.hand.size} Karten)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, + color = if (isCurrentTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface) } - Spacer(modifier = Modifier.height(8.dp)) - - LazyRow( - horizontalArrangement = Arrangement.spacedBy((-8).dp), - contentPadding = PaddingValues(horizontal = 16.dp) - ) { + LazyRow(horizontalArrangement = Arrangement.spacedBy((-8).dp), contentPadding = PaddingValues(horizontal = 16.dp)) { items(player.hand.size) { index -> val card = player.hand[index] val canPlay = topCard != null && card.canPlayOn(topCard, currentColor) - AnimatedCardEntry( - card = card, - onClick = { onCardClick(card) }, - modifier = Modifier.padding(horizontal = 4.dp), - isPlayable = canPlay && isCurrentTurn, - animationDelay = index * 30 - ) + AnimatedCardEntry(card = card, onClick = { onCardClick(card) }, modifier = Modifier.padding(horizontal = 4.dp), + isPlayable = canPlay && isCurrentTurn, animationDelay = index * 30) } } } @@ -718,38 +418,20 @@ private fun PlayerHand( // region --- ActionButtons --- @Composable -private fun ActionButtons( - state: GameState, - onDrawCard: () -> Unit, - onCallUno: () -> Unit -) { +private fun ActionButtons(state: GameState, onDrawCard: () -> Unit, onCallUno: () -> Unit) { val isMyTurn = state.currentPlayerIndex == 0 val player = state.players.firstOrNull() val hasTwoCards = player?.hand?.size == 2 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button(onClick = onDrawCard, enabled = isMyTurn) { - Text("Karte ziehen") - } - + Button(onClick = onDrawCard, enabled = isMyTurn) { Text("Karte ziehen") } val unoScale by animateFloatAsState( targetValue = if (hasTwoCards && isMyTurn && !(player?.hasCalledUno ?: true)) 1.1f else 1f, - animationSpec = infiniteRepeatable( - animation = tween(500, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ) + animationSpec = infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse) ) - - Button( - onClick = onCallUno, - enabled = isMyTurn && hasTwoCards && !(player?.hasCalledUno ?: true), - modifier = Modifier.scale(unoScale), - colors = ButtonDefaults.buttonColors( - containerColor = if (hasTwoCards) Color(0xFFFF6D00) else MaterialTheme.colorScheme.primary - ) - ) { - Text("UNO!") - } + Button(onClick = onCallUno, enabled = isMyTurn && hasTwoCards && !(player?.hasCalledUno ?: true), modifier = Modifier.scale(unoScale), + colors = ButtonDefaults.buttonColors(containerColor = if (hasTwoCards) Color(0xFFFF6D00) else MaterialTheme.colorScheme.primary) + ) { Text("UNO!") } } } @@ -759,27 +441,15 @@ private fun ActionButtons( @Composable fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) { - val infiniteTransition = rememberInfiniteTransition() - val celebrationScale by infiniteTransition.animateFloat( - 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(tween(400, easing = FastOutSlowInEasing), RepeatMode.Reverse) - ) + val inf = rememberInfiniteTransition() + val s by inf.animateFloat(0.8f, 1.1f, infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse)) + val r by inf.animateFloat(-3f, 3f, 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, - color = MaterialTheme.colorScheme.primary - ) + Text("Gewonnen!", modifier = Modifier.fillMaxWidth().graphicsLayer { scaleX = s; scaleY = s; rotationZ = r }, + textAlign = TextAlign.Center, fontSize = 28.sp, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.primary) }, text = { Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { @@ -788,9 +458,7 @@ fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) { Text("Herzlichen Glückwunsch!", style = MaterialTheme.typography.bodyLarge) } }, - confirmButton = { - Button(onClick = onDismiss, modifier = Modifier.scale(celebrationScale)) { Text("Nochmal spielen") } - } + confirmButton = { Button(onClick = onDismiss, modifier = Modifier.scale(s)) { Text("Nochmal spielen") } } ) } @@ -799,41 +467,23 @@ fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) { // region --- AnimatedColorPickerDialog --- @Composable -fun AnimatedColorPickerDialog( - onColorSelected: (CardColor) -> Unit, - onDismiss: () -> Unit -) { +fun AnimatedColorPickerDialog(onColorSelected: (CardColor) -> Unit, onDismiss: () -> Unit) { var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { visible = true } AlertDialog( onDismissRequest = onDismiss, - title = { - AnimatedVisibility(visible = visible, enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn()) { - Text("Farbe wahlen") - } - }, + title = { AnimatedVisibility(visible = visible, enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn()) { Text("Farbe wählen") } }, text = { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { CardColor.entries.forEachIndexed { index, color -> - val bgColor = when (color) { - CardColor.RED -> Color(0xFFE53935) - CardColor.GREEN -> Color(0xFF43A047) - 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)) + val bgColor = when (color) { CardColor.RED -> Color(0xFFE53935); CardColor.GREEN -> Color(0xFF43A047); 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)) ) { - Box( - modifier = Modifier.size(60.dp).clip(RoundedCornerShape(8.dp)).background(bgColor).clickable { onColorSelected(color) }, - contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.size(60.dp).clip(RoundedCornerShape(8.dp)).background(bgColor).clickable { onColorSelected(color) }, + contentAlignment = Alignment.Center) { Text(color.displayName, color = if (color == CardColor.YELLOW) Color.Black else Color.White, fontWeight = FontWeight.Bold) } }