fix: card now flies directly onto the discard pile stack

Replaced separate overlay approach with FlyingTopCard that renders
directly at the discard pile position. Card animates from off-screen
(bottom for player, top for AI) with spring physics into place.
This commit is contained in:
2026-08-02 18:40:48 +02:00
parent a7fb6ff94c
commit 673ed88d1c
@@ -15,159 +15,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.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.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 kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay 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 ---
@@ -175,12 +32,8 @@ private fun FlyingCardOverlay(
fun rememberPulseAnimation(): Float { fun rememberPulseAnimation(): Float {
val infiniteTransition = rememberInfiniteTransition() val infiniteTransition = rememberInfiniteTransition()
val pulse by infiniteTransition.animateFloat( val pulse by infiniteTransition.animateFloat(
initialValue = 0.8f, initialValue = 0.8f, targetValue = 1.2f,
targetValue = 1.2f, animationSpec = infiniteRepeatable(tween(600, easing = FastOutSlowInEasing), RepeatMode.Reverse)
animationSpec = infiniteRepeatable(
animation = tween(600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
) )
return pulse return pulse
} }
@@ -189,73 +42,98 @@ fun rememberPulseAnimation(): Float {
fun rememberGlowAnimation(): Float { fun rememberGlowAnimation(): Float {
val infiniteTransition = rememberInfiniteTransition() val infiniteTransition = rememberInfiniteTransition()
val glow by infiniteTransition.animateFloat( val glow by infiniteTransition.animateFloat(
initialValue = 0.3f, initialValue = 0.3f, targetValue = 1f,
targetValue = 1f, animationSpec = infiniteRepeatable(tween(800, easing = FastOutSlowInEasing), RepeatMode.Reverse)
animationSpec = infiniteRepeatable(
animation = tween(800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
) )
return glow return glow
} }
@Composable @Composable
fun AnimatedCardEntry( fun AnimatedCardEntry(
card: Card, card: Card, onClick: () -> Unit, modifier: Modifier = Modifier,
onClick: () -> Unit, isPlayable: Boolean = true, animationDelay: Int = 0
modifier: Modifier = Modifier,
isPlayable: Boolean = true,
animationDelay: Int = 0
) { ) {
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
LaunchedEffect(card) { LaunchedEffect(card) {
visible = false visible = false
kotlinx.coroutines.delay(animationDelay.toLong()) delay(animationDelay.toLong())
visible = true visible = true
} }
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = scaleIn( enter = scaleIn(initialScale = 0f, animationSpec = tween(300, delayMillis = animationDelay, easing = FastOutSlowInEasing))
initialScale = 0f, + fadeIn(animationSpec = tween(300, delayMillis = animationDelay))
animationSpec = tween(300, delayMillis = animationDelay, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(300, delayMillis = animationDelay))
) { ) {
UnoCardView(card = card, onClick = onClick, modifier = modifier, isPlayable = isPlayable) UnoCardView(card = card, onClick = onClick, modifier = modifier, isPlayable = isPlayable)
} }
} }
@Composable @Composable
fun AnimatedTopCard(card: Card) { fun FlyingTopCard(
var prevState by remember { mutableStateOf(card) } card: Card,
var playAnim by remember { mutableStateOf(false) } fromBottom: Boolean,
key: Any
) {
var hasAnimated by remember(key) { mutableStateOf(false) }
LaunchedEffect(card) { LaunchedEffect(key) {
if (card != prevState) { hasAnimated = false
playAnim = true delay(50)
kotlinx.coroutines.delay(400) hasAnimated = true
playAnim = false
prevState = card
}
} }
val scale by animateFloatAsState( val animatedOffsetY by animateFloatAsState(
targetValue = if (playAnim) 1.3f else 1f, targetValue = if (hasAnimated) 0f else if (fromBottom) 300f else -300f,
animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessLow) animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium),
label = "flyY"
) )
val rotation by animateFloatAsState( val animatedScale by animateFloatAsState(
targetValue = if (playAnim) 15f else 0f, targetValue = if (hasAnimated) 1f else 0.4f,
animationSpec = tween(400, easing = FastOutSlowInEasing) 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( Box(
modifier = Modifier.graphicsLayer { modifier = Modifier
scaleX = scale .graphicsLayer {
scaleY = scale translationY = animatedOffsetY
rotationZ = rotation 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 previousLastAction by remember { mutableStateOf(state.lastAction) }
var previousTopCard by remember { mutableStateOf(state.deck.topCard) } var previousTopCard by remember { mutableStateOf(state.deck.topCard) }
var lastPlayedByPlayerIndex by remember { mutableIntStateOf(0) } var lastPlayedByPlayerIndex by remember { mutableIntStateOf(0) }
var flyingCardKey by remember { mutableIntStateOf(0) }
val flyingState = rememberFlyingCardState() // Detect when top card changes → trigger flying animation
LaunchedEffect(state.deck.topCard) {
// Track who played the last card
LaunchedEffect(state.currentPlayerIndex, state.deck.topCard) {
val newTop = state.deck.topCard val newTop = state.deck.topCard
if (newTop != null && newTop != previousTopCard) { if (newTop != null && newTop != previousTopCard) {
// The card was played by the player whose turn it was BEFORE the index changed flyingCardKey++
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 previousTopCard = newTop
} }
} }
// Update who is about to play // Track who is about to play
LaunchedEffect(state.currentPlayerIndex) { LaunchedEffect(state.currentPlayerIndex) {
lastPlayedByPlayerIndex = state.currentPlayerIndex lastPlayedByPlayerIndex = state.currentPlayerIndex
} }
// UNO-Rufen Animation // UNO call overlay
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
kotlinx.coroutines.delay(1500) delay(1500)
showUnoCall = false showUnoCall = false
} }
previousLastAction = state.lastAction previousLastAction = state.lastAction
@@ -319,16 +180,14 @@ fun UnoGameScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(16.dp),
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
GameInfoBar(state) GameInfoBar(state)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
OpponentHands(state) OpponentHands(state)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
DrawPileAndTopCard(state, onDrawCard) DrawPileAndTopCard(state, onDrawCard, lastPlayedByPlayerIndex, flyingCardKey)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
PlayerHand( PlayerHand(
player = state.players.firstOrNull(), player = state.players.firstOrNull(),
@@ -357,50 +216,28 @@ fun UnoGameScreen(
label = "action" label = "action"
) { action -> ) { action ->
if (action.isNotEmpty()) { 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 // UNO call overlay
AnimatedVisibility( AnimatedVisibility(
visible = showUnoCall, visible = showUnoCall,
enter = scaleIn( enter = scaleIn(initialScale = 0f, animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessLow)) + fadeIn(),
initialScale = 0f,
animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessLow)
) + fadeIn(),
exit = scaleOut(targetScale = 2f) + fadeOut(), exit = scaleOut(targetScale = 2f) + fadeOut(),
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) { ) {
Text( Text(
text = "UNO!", "UNO!", fontSize = 72.sp, fontWeight = FontWeight.ExtraBold, color = Color(0xFFFF6D00),
fontSize = 72.sp, modifier = Modifier.shadow(16.dp, RoundedCornerShape(16.dp))
fontWeight = FontWeight.ExtraBold,
color = Color(0xFFFF6D00),
modifier = Modifier
.shadow(16.dp, RoundedCornerShape(16.dp))
.background(Color.White.copy(alpha = 0.9f), RoundedCornerShape(16.dp)) .background(Color.White.copy(alpha = 0.9f), RoundedCornerShape(16.dp))
.padding(horizontal = 32.dp, vertical = 16.dp) .padding(horizontal = 32.dp, vertical = 16.dp)
) )
} }
// Winner celebration overlay
if (state.isFinished) { if (state.isFinished) {
WinnerCelebration(state.winner) { WinnerCelebration(state.winner) {}
// dismiss handled by caller
}
} }
} }
@@ -411,10 +248,7 @@ fun UnoGameScreen(
showColorPicker = false showColorPicker = false
pendingWildCard = null pendingWildCard = null
}, },
onDismiss = { onDismiss = { showColorPicker = false; pendingWildCard = null }
showColorPicker = false
pendingWildCard = null
}
) )
} }
} }
@@ -425,41 +259,21 @@ fun UnoGameScreen(
private fun GameInfoBar(state: GameState) { private fun GameInfoBar(state: GameState) {
val directionRotation by animateFloatAsState( val directionRotation by animateFloatAsState(
targetValue = if (state.direction == GameDirection.CLOCKWISE) 0f else 180f, targetValue = if (state.direction == GameDirection.CLOCKWISE) 0f else 180f,
animationSpec = tween(500, easing = FastOutSlowInEasing), animationSpec = tween(500, easing = FastOutSlowInEasing), label = "direction"
label = "direction"
) )
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
Card( Row(modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
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) { Row(verticalAlignment = Alignment.CenterVertically) {
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("↗️", 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) Text("Stapel: ${state.deck.drawPile.size}", style = MaterialTheme.typography.bodyMedium)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
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.GREEN -> Color(0xFF43A047); CardColor.BLUE -> Color(0xFF1E88E5); CardColor.YELLOW -> Color(0xFFFDD835) }
CardColor.RED -> Color(0xFFE53935) Box(modifier = Modifier.size(14.dp).clip(RoundedCornerShape(3.dp)).background(bgColor))
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 @Composable
private fun OpponentHands(state: GameState) { private fun OpponentHands(state: GameState) {
Row( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
state.players.drop(1).forEach { player -> state.players.drop(1).forEach { player ->
OpponentHandView(player, isActive = state.currentPlayerIndex == state.players.indexOf(player)) OpponentHandView(player, isActive = state.currentPlayerIndex == state.players.indexOf(player))
} }
@@ -485,58 +296,31 @@ private fun OpponentHands(state: GameState) {
@Composable @Composable
private fun OpponentHandView(player: Player, isActive: Boolean) { private fun OpponentHandView(player: Player, isActive: Boolean) {
val pulse = if (isActive) rememberPulseAnimation() else 1f 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) { if (isActive) {
Box( 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)) Spacer(modifier = Modifier.height(2.dp))
} }
Text( Text(player.name, style = MaterialTheme.typography.labelMedium, fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Bold,
text = player.name, color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface)
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)) Spacer(modifier = Modifier.height(4.dp))
Row { Row {
repeat(minOf(player.hand.size, 5)) { repeat(minOf(player.hand.size, 5)) {
AnimatedVisibility( AnimatedVisibility(visible = true, enter = scaleIn(initialScale = 0f, animationSpec = tween(200, delayMillis = it * 50))) { CardBack() }
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))
} }
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 @Composable
private fun CardBack() { private fun CardBack() {
Box( Box(
modifier = Modifier modifier = Modifier.size(width = 30.dp, height = 45.dp).clip(RoundedCornerShape(4.dp))
.size(width = 30.dp, height = 45.dp) .background(Color(0xFF1A237E)).border(1.dp, Color.White, RoundedCornerShape(4.dp)).padding(2.dp),
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF1A237E))
.border(1.dp, Color.White, RoundedCornerShape(4.dp))
.padding(2.dp),
contentAlignment = Alignment.Center 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 // endregion
@@ -544,32 +328,19 @@ private fun CardBack() {
// region --- DrawPileAndTopCard --- // region --- DrawPileAndTopCard ---
@Composable @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) } var drawHover by remember { mutableStateOf(false) }
val drawScale by animateFloatAsState( val drawScale by animateFloatAsState(targetValue = if (drawHover) 1.1f else 1f, animationSpec = spring(dampingRatio = 0.5f))
targetValue = if (drawHover) 1.1f else 1f,
animationSpec = spring(dampingRatio = 0.5f)
)
Row( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier.scale(drawScale).pointerInput(Unit) {
.scale(drawScale) detectTapGestures(
.pointerInput(Unit) { onPress = { drawHover = true; tryAwaitRelease(); drawHover = false },
detectTapGestures( onTap = { onDrawCard() }
onPress = { )
drawHover = true }
tryAwaitRelease()
drawHover = false
},
onTap = { onDrawCard() }
)
}
) { ) {
CardBack() CardBack()
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@@ -579,7 +350,8 @@ private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit) {
Spacer(modifier = Modifier.width(32.dp)) Spacer(modifier = Modifier.width(32.dp))
state.deck.topCard?.let { card -> 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 --- // region --- UnoCardView ---
@Composable @Composable
fun UnoCardView( fun UnoCardView(card: Card, onClick: () -> Unit, modifier: Modifier = Modifier, isPlayable: Boolean = true) {
card: Card,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isPlayable: Boolean = true
) {
val glowAlpha = if (isPlayable) rememberGlowAnimation() else 0f 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 val textColor = if (card.color == CardColor.YELLOW) Color.Black else Color.White
Box( Box(
modifier = modifier modifier = modifier.size(width = 50.dp, height = 75.dp)
.size(width = 50.dp, height = 75.dp) .shadow(if (isPlayable) (4 * glowAlpha).dp else 2.dp, RoundedCornerShape(6.dp), ambientColor = backgroundColor, spotColor = backgroundColor)
.shadow( .clip(RoundedCornerShape(6.dp)).background(backgroundColor)
elevation = if (isPlayable) (4 * glowAlpha).dp else 2.dp, .then(if (isPlayable) Modifier.border((1.5f + glowAlpha * 1.5f).dp, Color.White.copy(alpha = glowAlpha * 0.8f), RoundedCornerShape(6.dp))
shape = RoundedCornerShape(6.dp), else Modifier.border(2.dp, Color.Gray.copy(alpha = 0.5f), RoundedCornerShape(6.dp)))
ambientColor = backgroundColor, .clickable(enabled = isPlayable) { onClick() }.padding(4.dp),
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),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
horizontalAlignment = Alignment.CenterHorizontally, Text(card.displayValue, color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center)
verticalArrangement = Arrangement.Center if (card.color != null) { Text(card.color.displayName, color = textColor, fontSize = 7.sp) }
) {
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)
}
} }
} }
} }
@@ -655,59 +387,27 @@ fun UnoCardView(
// region --- PlayerHand --- // region --- PlayerHand ---
@Composable @Composable
private fun PlayerHand( private fun PlayerHand(player: Player?, topCard: Card?, currentColor: CardColor?, isCurrentTurn: Boolean, onCardClick: (Card) -> Unit) {
player: Player?,
topCard: Card?,
currentColor: CardColor?,
isCurrentTurn: Boolean,
onCardClick: (Card) -> Unit
) {
if (player == null) return if (player == null) return
val turnScale by animateFloatAsState(targetValue = if (isCurrentTurn) 1.02f else 1f, animationSpec = spring(dampingRatio = 0.6f))
val turnScale by animateFloatAsState( Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.scale(turnScale)) {
targetValue = if (isCurrentTurn) 1.02f else 1f,
animationSpec = spring(dampingRatio = 0.6f)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.scale(turnScale)
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (isCurrentTurn) { if (isCurrentTurn) {
val dotPulse = rememberPulseAnimation() val dotPulse = rememberPulseAnimation()
Box( Box(modifier = Modifier.size((8 * dotPulse).dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.primary))
modifier = Modifier
.size((8 * dotPulse).dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
} }
Text( Text("${player.name} (${player.hand.size} Karten)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold,
text = "${player.name} (${player.hand.size} Karten)", color = if (isCurrentTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface)
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (isCurrentTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
} }
Spacer(modifier = Modifier.height(8.dp)) 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 -> items(player.hand.size) { index ->
val card = player.hand[index] val card = player.hand[index]
val canPlay = topCard != null && card.canPlayOn(topCard, currentColor) val canPlay = topCard != null && card.canPlayOn(topCard, currentColor)
AnimatedCardEntry( AnimatedCardEntry(card = card, onClick = { onCardClick(card) }, modifier = Modifier.padding(horizontal = 4.dp),
card = card, isPlayable = canPlay && isCurrentTurn, animationDelay = index * 30)
onClick = { onCardClick(card) },
modifier = Modifier.padding(horizontal = 4.dp),
isPlayable = canPlay && isCurrentTurn,
animationDelay = index * 30
)
} }
} }
} }
@@ -718,38 +418,20 @@ private fun PlayerHand(
// region --- ActionButtons --- // region --- ActionButtons ---
@Composable @Composable
private fun ActionButtons( private fun ActionButtons(state: GameState, onDrawCard: () -> Unit, onCallUno: () -> Unit) {
state: GameState,
onDrawCard: () -> Unit,
onCallUno: () -> Unit
) {
val isMyTurn = state.currentPlayerIndex == 0 val isMyTurn = state.currentPlayerIndex == 0
val player = state.players.firstOrNull() val player = state.players.firstOrNull()
val hasTwoCards = player?.hand?.size == 2 val hasTwoCards = player?.hand?.size == 2
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onDrawCard, enabled = isMyTurn) { Button(onClick = onDrawCard, enabled = isMyTurn) { Text("Karte ziehen") }
Text("Karte ziehen")
}
val unoScale by animateFloatAsState( val unoScale by animateFloatAsState(
targetValue = if (hasTwoCards && isMyTurn && !(player?.hasCalledUno ?: true)) 1.1f else 1f, targetValue = if (hasTwoCards && isMyTurn && !(player?.hasCalledUno ?: true)) 1.1f else 1f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse)
animation = tween(500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
) )
Button(onClick = onCallUno, enabled = isMyTurn && hasTwoCards && !(player?.hasCalledUno ?: true), modifier = Modifier.scale(unoScale),
Button( colors = ButtonDefaults.buttonColors(containerColor = if (hasTwoCards) Color(0xFFFF6D00) else MaterialTheme.colorScheme.primary)
onClick = onCallUno, ) { Text("UNO!") }
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 @Composable
fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) { fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) {
val infiniteTransition = rememberInfiniteTransition() val inf = rememberInfiniteTransition()
val celebrationScale by infiniteTransition.animateFloat( val s by inf.animateFloat(0.8f, 1.1f, infiniteRepeatable(tween(500, easing = FastOutSlowInEasing), RepeatMode.Reverse))
initialValue = 0.8f, targetValue = 1.1f, val r by inf.animateFloat(-3f, 3f, infiniteRepeatable(tween(400, easing = FastOutSlowInEasing), RepeatMode.Reverse))
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)
)
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = {
Text( Text("Gewonnen!", modifier = Modifier.fillMaxWidth().graphicsLayer { scaleX = s; scaleY = s; rotationZ = r },
text = "Gewonnen!", textAlign = TextAlign.Center, fontSize = 28.sp, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.primary)
modifier = Modifier.fillMaxWidth().graphicsLayer {
scaleX = celebrationScale; scaleY = celebrationScale; rotationZ = celebrationRotation
},
textAlign = TextAlign.Center, fontSize = 28.sp, fontWeight = FontWeight.ExtraBold,
color = MaterialTheme.colorScheme.primary
)
}, },
text = { text = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { 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) Text("Herzlichen Glückwunsch!", style = MaterialTheme.typography.bodyLarge)
} }
}, },
confirmButton = { confirmButton = { Button(onClick = onDismiss, modifier = Modifier.scale(s)) { Text("Nochmal spielen") } }
Button(onClick = onDismiss, modifier = Modifier.scale(celebrationScale)) { Text("Nochmal spielen") }
}
) )
} }
@@ -799,41 +467,23 @@ fun WinnerCelebration(winner: Player?, onDismiss: () -> Unit) {
// region --- AnimatedColorPickerDialog --- // region --- AnimatedColorPickerDialog ---
@Composable @Composable
fun AnimatedColorPickerDialog( fun AnimatedColorPickerDialog(onColorSelected: (CardColor) -> Unit, onDismiss: () -> Unit) {
onColorSelected: (CardColor) -> Unit,
onDismiss: () -> Unit
) {
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true } LaunchedEffect(Unit) { visible = true }
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { 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 = { text = {
Row( Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
CardColor.entries.forEachIndexed { index, color -> CardColor.entries.forEachIndexed { index, color ->
val bgColor = when (color) { val bgColor = when (color) { CardColor.RED -> Color(0xFFE53935); CardColor.GREEN -> Color(0xFF43A047); CardColor.BLUE -> Color(0xFF1E88E5); CardColor.YELLOW -> Color(0xFFFDD835) }
CardColor.RED -> Color(0xFFE53935) AnimatedVisibility(visible = visible,
CardColor.GREEN -> Color(0xFF43A047) enter = scaleIn(initialScale = 0f, animationSpec = tween(300, delayMillis = index * 80, easing = FastOutSlowInEasing))
CardColor.BLUE -> Color(0xFF1E88E5) + fadeIn(tween(300, delayMillis = index * 80))
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( 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) {
contentAlignment = Alignment.Center
) {
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)
} }
} }