From f0fba426dc7d41deca5c1726a779a00a6ab7ed2a Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 2 Aug 2026 18:45:27 +0200 Subject: [PATCH] fix: flying card now animates from player hand to discard pile Restored FlyingCardOverlay approach with correct target position. Tracks discard pile position via onGloballyPositioned and animates card from hand position to that exact target using coroutine-based Animatable for smooth spring physics. --- .../kotlin/com/uno/shared/UnoGameScreen.kt | 269 +++++++++++++----- 1 file changed, 203 insertions(+), 66 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt b/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt index 6a44842..782d34e 100644 --- a/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt +++ b/shared/src/commonMain/kotlin/com/uno/shared/UnoGameScreen.kt @@ -18,13 +18,20 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.uno.game.GameEngine import com.uno.game.model.* import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.coroutineScope +import kotlin.math.roundToInt // region --- Animations --- @@ -68,37 +75,70 @@ fun AnimatedCardEntry( } } -@Composable -fun FlyingTopCard( - card: Card, - fromBottom: Boolean, - key: Any -) { - var hasAnimated by remember(key) { mutableStateOf(false) } +// Flying card state tracking which player just played +class FlyingCardState { + var isActive by mutableStateOf(false) + private set + var fromX by mutableStateOf(0f) + private set + var fromY by mutableStateOf(0f) + private set + var card by mutableStateOf(null) + private set + var isWild by mutableStateOf(false) + private set - LaunchedEffect(key) { - hasAnimated = false - delay(50) - hasAnimated = true + private val animX = Animatable(0f) + private val animY = Animatable(0f) + private val animScale = Animatable(0.3f) + private val animRotation = Animatable(0f) + + val x by animX.asState() + val y by animY.asState() + val scale by animScale.asState() + val rotation by animRotation.asState() + + var currentCard by mutableStateOf(null) + private set + + suspend fun flyTo( + c: Card, startX: Float, startY: Float, + targetX: Float, targetY: Float + ) { + card = c + isWild = c.isWild + fromX = startX + fromY = startY + isActive = true + currentCard = c + + animX.snapTo(startX - targetX) + animY.snapTo(startY - targetY) + animScale.snapTo(0.3f) + animRotation.snapTo(0f) + + kotlinx.coroutines.coroutineScope { + launch { animX.animateTo(0f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium)) } + launch { animY.animateTo(0f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium)) } + launch { animScale.animateTo(1f, spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMedium)) } + launch { animRotation.animateTo((Math.random() * 30 - 15).toFloat(), spring(dampingRatio = 0.6f, stiffness = Spring.StiffnessLow)) } + } + + delay(200) + isActive = false + currentCard = null } +} - 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 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() } +@Composable +fun FlyingCardOverlay( + state: FlyingCardState, + screenCenterX: Float, + screenCenterY: Float +) { + if (!state.isActive || state.card == null) return + val card = state.card!! val backgroundColor = when (card.color) { CardColor.RED -> Color(0xFFE53935) CardColor.GREEN -> Color(0xFF43A047) @@ -110,28 +150,31 @@ fun FlyingTopCard( Box( 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), + .fillMaxSize() + .pointerInput(Unit) {}, contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Box( + modifier = Modifier + .offset { IntOffset(state.x.roundToInt(), state.y.roundToInt()) } + .scale(state.scale) + .graphicsLayer { rotationZ = state.rotation; shadowElevation = 20f } + .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.7f), RoundedCornerShape(8.dp)) + .padding(6.dp), + contentAlignment = Alignment.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) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(card.displayValue, color = textColor, fontSize = 22.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + if (card.color != null) { + Text(card.color.displayName, color = textColor, fontSize = 9.sp) + } } } } @@ -151,23 +194,23 @@ fun UnoGameScreen( var showUnoCall by remember { mutableStateOf(false) } var previousLastAction by remember { mutableStateOf(state.lastAction) } var previousTopCard by remember { mutableStateOf(state.deck.topCard) } - var lastPlayedByPlayerIndex by remember { mutableIntStateOf(0) } - var flyingCardKey by remember { mutableIntStateOf(0) } + + // Track player hand positions (screen coordinates) + var playerHandPosition by remember { mutableStateOf(IntOffset.Zero) } + // Track discard pile position (screen coordinates) + var discardPilePosition by remember { mutableStateOf(IntOffset.Zero) } + + val flyingState = remember { FlyingCardState() } + val coroutineScope = rememberCoroutineScope() // Detect when top card changes → trigger flying animation LaunchedEffect(state.deck.topCard) { val newTop = state.deck.topCard if (newTop != null && newTop != previousTopCard) { - flyingCardKey++ previousTopCard = newTop } } - // Track who is about to play - LaunchedEffect(state.currentPlayerIndex) { - lastPlayedByPlayerIndex = state.currentPlayerIndex - } - // UNO call overlay LaunchedEffect(state.lastAction) { if (state.lastAction != previousLastAction && state.lastAction.contains("UNO")) { @@ -187,23 +230,44 @@ fun UnoGameScreen( Spacer(modifier = Modifier.height(12.dp)) OpponentHands(state) Spacer(modifier = Modifier.height(12.dp)) - DrawPileAndTopCard(state, onDrawCard, lastPlayedByPlayerIndex, flyingCardKey) + + // Discard pile with position tracking + DrawPileAndTopCard( + state = state, + onDrawCard = onDrawCard, + onPositionMeasured = { pos -> discardPilePosition = pos } + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Player hand with position tracking PlayerHand( player = state.players.firstOrNull(), topCard = state.deck.topCard, currentColor = state.currentColor, isCurrentTurn = state.currentPlayerIndex == 0, onCardClick = { card -> - lastPlayedByPlayerIndex = 0 + // Trigger flying animation from hand to discard pile if (card.isWild) { pendingWildCard = card showColorPicker = true } else { - onPlayCard(card, null) + coroutineScope.launch { + flyingState.flyTo( + c = card, + startX = playerHandPosition.x.toFloat(), + startY = playerHandPosition.y.toFloat(), + targetX = discardPilePosition.x.toFloat(), + targetY = discardPilePosition.y.toFloat() + ) + delay(100) + onPlayCard(card, null) + } } - } + }, + onPositionMeasured = { pos -> playerHandPosition = pos } ) + Spacer(modifier = Modifier.height(8.dp)) ActionButtons(state, onDrawCard, onCallUno) @@ -221,6 +285,13 @@ fun UnoGameScreen( } } + // Flying card overlay (renders on top of everything) + FlyingCardOverlay( + state = flyingState, + screenCenterX = discardPilePosition.x.toFloat(), + screenCenterY = discardPilePosition.y.toFloat() + ) + // UNO call overlay AnimatedVisibility( visible = showUnoCall, @@ -244,7 +315,19 @@ fun UnoGameScreen( if (showColorPicker) { AnimatedColorPickerDialog( onColorSelected = { color -> - pendingWildCard?.let { onPlayCard(it, color) } + pendingWildCard?.let { card -> + coroutineScope.launch { + flyingState.flyTo( + c = card, + startX = playerHandPosition.x.toFloat(), + startY = playerHandPosition.y.toFloat(), + targetX = discardPilePosition.x.toFloat(), + targetY = discardPilePosition.y.toFloat() + ) + delay(100) + onPlayCard(card, color) + } + } showColorPicker = false pendingWildCard = null }, @@ -328,11 +411,23 @@ private fun CardBack() { // region --- DrawPileAndTopCard --- @Composable -private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit, lastPlayedByPlayerIndex: Int, flyingCardKey: Int) { +private fun DrawPileAndTopCard( + state: GameState, + onDrawCard: () -> Unit, + onPositionMeasured: (IntOffset) -> Unit +) { var drawHover by remember { mutableStateOf(false) } val drawScale by animateFloatAsState(targetValue = if (drawHover) 1.1f else 1f, animationSpec = spring(dampingRatio = 0.5f)) + val density = LocalDensity.current - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth().onGloballyPositioned { coords -> + val pos = coords.positionInWindow() + onPositionMeasured(IntOffset(pos.x.roundToInt(), pos.y.roundToInt())) + }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.scale(drawScale).pointerInput(Unit) { @@ -349,9 +444,39 @@ private fun DrawPileAndTopCard(state: GameState, onDrawCard: () -> Unit, lastPla Spacer(modifier = Modifier.width(32.dp)) - state.deck.topCard?.let { card -> - val fromBottom = lastPlayedByPlayerIndex == 0 - FlyingTopCard(card = card, fromBottom = fromBottom, key = flyingCardKey) + // Top card with flying animation + Box(modifier = Modifier.onGloballyPositioned { coords -> + val pos = coords.positionInWindow() + onPositionMeasured(IntOffset(pos.x.roundToInt(), pos.y.roundToInt())) + }) { + state.deck.topCard?.let { card -> + TopCardView(card) + } + } + } +} + +@Composable +private fun TopCardView(card: Card) { + 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 = 60.dp, height = 90.dp) + .shadow(8.dp, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(8.dp)) + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(card.displayValue, color = textColor, fontSize = 22.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + if (card.color != null) { Text(card.color.displayName, color = textColor, fontSize = 9.sp) } } } } @@ -387,11 +512,23 @@ fun UnoCardView(card: Card, onClick: () -> Unit, modifier: Modifier = Modifier, // 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, onPositionMeasured: (IntOffset) -> Unit +) { if (player == null) return val turnScale by animateFloatAsState(targetValue = if (isCurrentTurn) 1.02f else 1f, animationSpec = spring(dampingRatio = 0.6f)) + val density = LocalDensity.current - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.scale(turnScale)) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .scale(turnScale) + .onGloballyPositioned { coords -> + val pos = coords.positionInWindow() + onPositionMeasured(IntOffset(pos.x.roundToInt(), pos.y.roundToInt())) + } + ) { Row(verticalAlignment = Alignment.CenterVertically) { if (isCurrentTurn) { val dotPulse = rememberPulseAnimation()