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.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<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 ---
@@ -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)
}
}